react-three-game 0.0.7 → 0.0.9

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.
@@ -0,0 +1,460 @@
1
+ "use client";
2
+
3
+ import { MapControls, TransformControls } from "@react-three/drei";
4
+ import { useState, useRef, useEffect, forwardRef, useMemo, useCallback } from "react";
5
+ import { Vector3, Euler, Quaternion, ClampToEdgeWrapping, DoubleSide, Group, Object3D, RepeatWrapping, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
6
+ import { Prefab, GameObject as GameObjectType } from "./types";
7
+ import { getComponent } from "./components/ComponentRegistry";
8
+ import { ThreeEvent } from "@react-three/fiber";
9
+ import { loadModel } from "../dragdrop/modelLoader";
10
+ import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
11
+
12
+ // register all components
13
+ import { registerComponent } from './components/ComponentRegistry';
14
+ import components from './components/';
15
+ components.forEach(registerComponent);
16
+
17
+ function updatePrefabNode(root: GameObjectType, id: string, update: (node: GameObjectType) => GameObjectType): GameObjectType {
18
+ if (root.id === id) {
19
+ return update(root);
20
+ }
21
+ if (root.children) {
22
+ return {
23
+ ...root,
24
+ children: root.children.map(child => updatePrefabNode(child, id, update))
25
+ };
26
+ }
27
+ return root;
28
+ }
29
+
30
+ export const PrefabRoot = forwardRef<Group, {
31
+ editMode?: boolean;
32
+ data: Prefab;
33
+ onPrefabChange?: (data: Prefab) => void;
34
+ selectedId?: string | null;
35
+ onSelect?: (id: string | null) => void;
36
+ transformMode?: "translate" | "rotate" | "scale";
37
+ setTransformMode?: (mode: "translate" | "rotate" | "scale") => void;
38
+ basePath?: string;
39
+ }>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, setTransformMode, basePath = "" }, ref) => {
40
+ const [loadedModels, setLoadedModels] = useState<Record<string, Object3D>>({});
41
+ const [loadedTextures, setLoadedTextures] = useState<Record<string, Texture>>({});
42
+ // const [prefabRoot, setPrefabRoot] = useState<Prefab>(data); // Removed local state
43
+ const loadingRefs = useRef<Set<string>>(new Set());
44
+ const objectRefs = useRef<Record<string, Object3D | null>>({});
45
+ const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
46
+
47
+ const registerRef = useCallback((id: string, obj: Object3D | null) => {
48
+ objectRefs.current[id] = obj;
49
+ if (id === selectedId) {
50
+ setSelectedObject(obj);
51
+ }
52
+ }, [selectedId]);
53
+
54
+ useEffect(() => {
55
+ if (selectedId) {
56
+ setSelectedObject(objectRefs.current[selectedId] || null);
57
+ } else {
58
+ setSelectedObject(null);
59
+ }
60
+ }, [selectedId]);
61
+
62
+
63
+ // const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate"); // Removed local state
64
+
65
+ const updateNode = (updater: (node: GameObjectType) => GameObjectType) => {
66
+ if (!selectedId || !onPrefabChange) return;
67
+ const newRoot = updatePrefabNode(data.root, selectedId, updater);
68
+ onPrefabChange({ ...data, root: newRoot });
69
+ };
70
+
71
+ const onTransformChange = () => {
72
+ if (!selectedId || !onPrefabChange) return;
73
+ const obj = objectRefs.current[selectedId];
74
+ if (!obj) return;
75
+
76
+ // 1. Get world matrix from the actual Three object
77
+ const worldMatrix = obj.matrixWorld.clone();
78
+
79
+ // 2. Compute parent world matrix from the prefab tree
80
+ const parentWorld = computeParentWorldMatrix(data.root, selectedId);
81
+ const parentInv = parentWorld.clone().invert();
82
+
83
+ // 3. Convert world -> local
84
+ const localMatrix = new Matrix4().multiplyMatrices(parentInv, worldMatrix);
85
+
86
+ const lp = new Vector3();
87
+ const lq = new Quaternion();
88
+ const ls = new Vector3();
89
+ localMatrix.decompose(lp, lq, ls);
90
+
91
+ const le = new Euler().setFromQuaternion(lq);
92
+
93
+ // 4. Write back LOCAL transform into the prefab node
94
+ const newRoot = updatePrefabNode(data.root, selectedId, (node) => ({
95
+ ...node,
96
+ components: {
97
+ ...node?.components,
98
+ transform: {
99
+ type: "Transform",
100
+ properties: {
101
+ position: [lp.x, lp.y, lp.z] as [number, number, number],
102
+ rotation: [le.x, le.y, le.z] as [number, number, number],
103
+ scale: [ls.x, ls.y, ls.z] as [number, number, number],
104
+ },
105
+ },
106
+ geometry: node.components?.geometry,
107
+ material: node.components?.material,
108
+ model: node.components?.model,
109
+ },
110
+ }));
111
+
112
+ onPrefabChange({ ...data, root: newRoot });
113
+ };
114
+
115
+
116
+ useEffect(() => {
117
+ const loadAssets = async () => {
118
+ const modelsToLoad = new Set<string>();
119
+ const texturesToLoad = new Set<string>();
120
+
121
+ const traverse = (node?: GameObjectType | null) => {
122
+ if (!node) return;
123
+ if (node.components?.model?.properties?.filename) {
124
+ modelsToLoad.add(node.components.model.properties.filename);
125
+ }
126
+ if (node.components?.material?.properties?.texture) {
127
+ texturesToLoad.add(node.components.material.properties.texture);
128
+ }
129
+ node.children?.forEach(traverse);
130
+ };
131
+ traverse(data.root);
132
+
133
+ for (const filename of modelsToLoad) {
134
+ if (!loadedModels[filename] && !loadingRefs.current.has(filename)) {
135
+ loadingRefs.current.add(filename);
136
+ const result = await loadModel(filename, basePath);
137
+ if (result.success && result.model) {
138
+ setLoadedModels(prev => ({ ...prev, [filename]: result.model }));
139
+ }
140
+ }
141
+ }
142
+
143
+ const textureLoader = new TextureLoader();
144
+ for (const filename of texturesToLoad) {
145
+ if (!loadedTextures[filename] && !loadingRefs.current.has(filename)) {
146
+ loadingRefs.current.add(filename);
147
+ const texturePath = basePath ? `${basePath}/${filename}` : filename;
148
+ textureLoader.load(texturePath, (texture) => {
149
+ texture.colorSpace = SRGBColorSpace;
150
+ setLoadedTextures(prev => ({ ...prev, [filename]: texture }));
151
+ });
152
+ }
153
+ }
154
+ };
155
+ loadAssets();
156
+ }, [data, loadedModels, loadedTextures]);
157
+
158
+
159
+
160
+ return <group ref={ref}>
161
+ <GameInstanceProvider models={loadedModels} onSelect={editMode ? onSelect : undefined} registerRef={registerRef}>
162
+ <GameObjectRenderer
163
+ gameObject={data.root}
164
+ selectedId={selectedId}
165
+ onSelect={editMode ? onSelect : undefined}
166
+ registerRef={registerRef}
167
+ loadedModels={loadedModels}
168
+ loadedTextures={loadedTextures}
169
+ editMode={editMode}
170
+ parentMatrix={new Matrix4()} // 👈 identity = world root
171
+ />
172
+ </GameInstanceProvider>
173
+
174
+
175
+ {editMode && <>
176
+ <MapControls makeDefault />
177
+
178
+ {selectedId && selectedObject && (
179
+ <TransformControls
180
+ object={selectedObject}
181
+ mode={transformMode}
182
+ space="local"
183
+ onObjectChange={onTransformChange}
184
+ />
185
+ )}
186
+ </>}
187
+ </group>;
188
+ });
189
+
190
+ interface GameObjectRendererProps {
191
+ gameObject?: GameObjectType | null;
192
+ selectedId?: string | null;
193
+ onSelect?: (id: string) => void;
194
+ registerRef: (id: string, obj: Object3D | null) => void;
195
+ loadedModels: Record<string, Object3D>;
196
+ loadedTextures: Record<string, Texture>;
197
+ editMode?: boolean;
198
+ parentMatrix?: Matrix4; // 👈 new
199
+ }
200
+
201
+
202
+ function GameObjectRenderer({
203
+ gameObject,
204
+ selectedId,
205
+ onSelect,
206
+ registerRef,
207
+ loadedModels,
208
+ loadedTextures,
209
+ editMode,
210
+ parentMatrix = new Matrix4(),
211
+ }: GameObjectRendererProps) {
212
+
213
+ // Early return if gameObject is null or undefined
214
+ if (!gameObject) return null;
215
+
216
+ // Build a small context object to avoid long param lists
217
+ const ctx = { gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode };
218
+
219
+ // --- 1. Transform (local + world) ---
220
+ const transformProps = getNodeTransformProps(gameObject);
221
+ const localMatrix = new Matrix4().compose(
222
+ new Vector3(...transformProps.position),
223
+ new Quaternion().setFromEuler(new Euler(...transformProps.rotation)),
224
+ new Vector3(...transformProps.scale)
225
+ );
226
+ const worldMatrix = parentMatrix.clone().multiply(localMatrix);
227
+
228
+ // preserve click/drag detection from previous implementation
229
+ const clickValid = useRef(false);
230
+ const handlePointerDown = (e: ThreeEvent<PointerEvent>) => {
231
+ e.stopPropagation();
232
+ clickValid.current = true;
233
+ };
234
+ const handlePointerMove = () => {
235
+ if (clickValid.current) clickValid.current = false;
236
+ };
237
+ const handlePointerUp = (e: ThreeEvent<PointerEvent>) => {
238
+ if (clickValid.current) {
239
+ e.stopPropagation();
240
+ onSelect?.(gameObject.id);
241
+ }
242
+ clickValid.current = false;
243
+ };
244
+
245
+ if (!gameObject.enabled || !gameObject.visible) return null;
246
+
247
+ // --- 2. If instanced, short-circuit to a tiny clean branch ---
248
+ const isInstanced = !!gameObject.components?.model?.properties?.instanced;
249
+ if (isInstanced) {
250
+ return renderInstancedNode(gameObject, worldMatrix, ctx);
251
+ }
252
+
253
+ // --- 3. Core content decided by component registry ---
254
+ const core = renderCoreNode(gameObject, ctx, parentMatrix);
255
+
256
+ // --- 5. Render children (always relative transforms) ---
257
+ const children = (gameObject.children ?? []).map((child) => (
258
+ <GameObjectRenderer
259
+ key={child.id}
260
+ gameObject={child}
261
+ selectedId={selectedId}
262
+ onSelect={onSelect}
263
+ registerRef={registerRef}
264
+ loadedModels={loadedModels}
265
+ loadedTextures={loadedTextures}
266
+ editMode={editMode}
267
+ parentMatrix={worldMatrix}
268
+ />
269
+ ));
270
+
271
+ // --- 4. Wrap with physics if needed ---
272
+ // Combine core and children so they both get wrapped by physics (if present)
273
+ const content = (
274
+ <>
275
+ {core}
276
+ {children}
277
+ </>
278
+ );
279
+ const physicsWrapped = wrapPhysicsIfNeeded(gameObject, content, ctx);
280
+
281
+ // --- 6. Final group wrapper ---
282
+ return (
283
+ <group
284
+ ref={(el) => registerRef(gameObject.id, el)}
285
+ position={transformProps.position}
286
+ rotation={transformProps.rotation}
287
+ scale={transformProps.scale}
288
+ onPointerDown={handlePointerDown}
289
+ onPointerMove={handlePointerMove}
290
+ onPointerUp={handlePointerUp}
291
+ >
292
+ {physicsWrapped}
293
+ </group>
294
+ );
295
+ }
296
+
297
+ // Helper: render an instanced GameInstance (terminal node)
298
+ function renderInstancedNode(gameObject: GameObjectType, worldMatrix: Matrix4, ctx: any) {
299
+ const physics = gameObject.components?.physics;
300
+ const wp = new Vector3();
301
+ const wq = new Quaternion();
302
+ const ws = new Vector3();
303
+ worldMatrix.decompose(wp, wq, ws);
304
+ const we = new Euler().setFromQuaternion(wq);
305
+ const modelUrl = gameObject.components?.model?.properties?.filename;
306
+ return (
307
+ <GameInstance
308
+ id={gameObject.id}
309
+ modelUrl={modelUrl}
310
+ position={[wp.x, wp.y, wp.z]}
311
+ rotation={[we.x, we.y, we.z]}
312
+ scale={[ws.x, ws.y, ws.z]}
313
+ physics={ctx.editMode ? undefined : (physics?.properties as any)}
314
+ />
315
+ );
316
+ }
317
+
318
+ // Helper: render main model/geometry content for a non-instanced node
319
+ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matrix4 | undefined) {
320
+ const geometry = gameObject.components?.geometry;
321
+ const material = gameObject.components?.material;
322
+ const modelComp = gameObject.components?.model;
323
+
324
+ const geometryDef = geometry ? getComponent('Geometry') : undefined;
325
+ const materialDef = material ? getComponent('Material') : undefined;
326
+
327
+ const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
328
+
329
+ // Generic component views (exclude geometry/material/model)
330
+ const contextProps = {
331
+ loadedModels: ctx.loadedModels,
332
+ loadedTextures: ctx.loadedTextures,
333
+ isSelected: ctx.selectedId === gameObject.id,
334
+ editMode: ctx.editMode,
335
+ parentMatrix,
336
+ registerRef: ctx.registerRef,
337
+ };
338
+ const allComponentViews = gameObject.components
339
+ ? Object.entries(gameObject.components)
340
+ .filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model')
341
+ .map(([key, comp]) => {
342
+ const def = getComponent(key);
343
+ if (!def || !def.View || !comp) return null;
344
+ return <def.View key={key} properties={comp.properties} {...contextProps} />;
345
+ })
346
+ : null;
347
+
348
+ // If we have a model (non-instanced) render it as a primitive with material override
349
+ if (isModelAvailable) {
350
+ const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
351
+ return (
352
+ <primitive object={modelObj}>
353
+ {material && materialDef && materialDef.View && (
354
+ <materialDef.View
355
+ key="material"
356
+ properties={material.properties}
357
+ loadedTextures={ctx.loadedTextures}
358
+ isSelected={ctx.selectedId === gameObject.id}
359
+ editMode={ctx.editMode}
360
+ parentMatrix={parentMatrix}
361
+ registerRef={ctx.registerRef}
362
+ />
363
+ )}
364
+ {allComponentViews}
365
+ </primitive>
366
+ );
367
+ }
368
+
369
+ // Otherwise, if geometry present, render a mesh
370
+ if (geometry && geometryDef && geometryDef.View) {
371
+ return (
372
+ <mesh>
373
+ <geometryDef.View key="geometry" properties={geometry.properties} {...contextProps} />
374
+ {material && materialDef && materialDef.View && (
375
+ <materialDef.View
376
+ key="material"
377
+ properties={material.properties}
378
+ loadedTextures={ctx.loadedTextures}
379
+ isSelected={ctx.selectedId === gameObject.id}
380
+ editMode={ctx.editMode}
381
+ parentMatrix={parentMatrix}
382
+ registerRef={ctx.registerRef}
383
+ />
384
+ )}
385
+ {allComponentViews}
386
+ </mesh>
387
+ );
388
+ }
389
+
390
+ // Default: render other component views (no geometry/model)
391
+ return <>{allComponentViews}</>;
392
+ }
393
+
394
+ // Helper: wrap core content with physics component when necessary
395
+ function wrapPhysicsIfNeeded(gameObject: GameObjectType, content: React.ReactNode, ctx: any) {
396
+ const physics = gameObject.components?.physics;
397
+ if (!physics) return content;
398
+ const physicsDef = getComponent('Physics');
399
+ if (!physicsDef || !physicsDef.View) return content;
400
+ return (
401
+ <physicsDef.View
402
+ properties={{ ...physics.properties, id: gameObject.id }}
403
+ registerRef={ctx.registerRef}
404
+ editMode={ctx.editMode}
405
+ >
406
+ {content}
407
+ </physicsDef.View>
408
+ );
409
+ }
410
+
411
+
412
+
413
+
414
+
415
+ export default PrefabRoot;
416
+
417
+ function getNodeTransformProps(node?: GameObjectType | null) {
418
+ const t = node?.components?.transform?.properties;
419
+ return {
420
+ position: t?.position ?? [0, 0, 0],
421
+ rotation: t?.rotation ?? [0, 0, 0],
422
+ scale: t?.scale ?? [1, 1, 1],
423
+ };
424
+ }
425
+
426
+ function computeParentWorldMatrix(root: GameObjectType, targetId: string): Matrix4 {
427
+ const identity = new Matrix4();
428
+
429
+ function traverse(node: GameObjectType, parentWorld: Matrix4): Matrix4 | null {
430
+ if (node.id === targetId) {
431
+ // parentWorld is what we want
432
+ return parentWorld.clone();
433
+ }
434
+
435
+ const { position, rotation, scale } = getNodeTransformProps(node);
436
+
437
+ const localPos = new Vector3(...position);
438
+ const localRot = new Euler(...rotation);
439
+ const localScale = new Vector3(...scale);
440
+
441
+ const localMat = new Matrix4().compose(
442
+ localPos,
443
+ new Quaternion().setFromEuler(localRot),
444
+ localScale
445
+ );
446
+
447
+ const worldMat = parentWorld.clone().multiply(localMat);
448
+
449
+ if (node.children) {
450
+ for (const child of node.children) {
451
+ const res = traverse(child, worldMat);
452
+ if (res) return res;
453
+ }
454
+ }
455
+
456
+ return null;
457
+ }
458
+
459
+ return traverse(root, identity) ?? identity;
460
+ }
@@ -0,0 +1,26 @@
1
+ import { FC } from "react";
2
+
3
+ export interface Component {
4
+ name: string;
5
+ Editor: FC<{ component: any; onUpdate: (newComp: any) => void; basePath?: string }>;
6
+ defaultProperties: any;
7
+ // Allow View to accept extra props for special cases (like material)
8
+ View?: FC<any>;
9
+ }
10
+
11
+ const REGISTRY: Record<string, Component> = {};
12
+
13
+ export function registerComponent(component: Component) {
14
+ if (REGISTRY[component.name]) {
15
+ throw new Error(`Component with name ${component.name} already registered.`);
16
+ }
17
+ REGISTRY[component.name] = component;
18
+ }
19
+
20
+ export function getComponent(name: string): Component | undefined {
21
+ return REGISTRY[name];
22
+ }
23
+
24
+ export function getAllComponents(): Record<string, Component> {
25
+ return { ...REGISTRY };
26
+ }
@@ -0,0 +1,43 @@
1
+ import { Component } from "./ComponentRegistry";
2
+
3
+ function GeometryComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
4
+ return <div>
5
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Type</label>
6
+ <select
7
+ className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
8
+ value={component.properties.geometryType}
9
+ onChange={e => onUpdate({ geometryType: e.target.value })}
10
+ >
11
+ <option value="box">Box</option>
12
+ <option value="sphere">Sphere</option>
13
+ <option value="plane">Plane</option>
14
+ </select>
15
+ </div>;
16
+ }
17
+
18
+ // View for Geometry component
19
+ function GeometryComponentView({ properties, children }: { properties: any, children?: React.ReactNode }) {
20
+ const { geometryType, args = [] } = properties;
21
+ // Only return the geometry node, do not wrap in mesh or group
22
+ switch (geometryType) {
23
+ case "box":
24
+ return <boxGeometry args={args as [number, number, number]} />;
25
+ case "sphere":
26
+ return <sphereGeometry args={args as [number, number?, number?]} />;
27
+ case "plane":
28
+ return <planeGeometry args={args as [number, number]} />;
29
+ default:
30
+ return <boxGeometry args={[1, 1, 1]} />;
31
+ }
32
+ }
33
+
34
+ const GeometryComponent: Component = {
35
+ name: 'Geometry',
36
+ Editor: GeometryComponentEditor,
37
+ View: GeometryComponentView,
38
+ defaultProperties: {
39
+ geometryType: 'box'
40
+ }
41
+ };
42
+
43
+ export default GeometryComponent;
@@ -0,0 +1,153 @@
1
+ import { TextureListViewer } from '../../assetviewer/page';
2
+ import { useEffect, useState } from 'react';
3
+ import { Component } from './ComponentRegistry';
4
+
5
+ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
6
+ const [textureFiles, setTextureFiles] = useState<string[]>([]);
7
+
8
+ useEffect(() => {
9
+ const base = basePath ? `${basePath}/` : '';
10
+ fetch(`/${base}textures/manifest.json`)
11
+ .then(r => r.json())
12
+ .then(data => setTextureFiles(Array.isArray(data) ? data : data.files || []))
13
+ .catch(console.error);
14
+ }, [basePath]);
15
+
16
+ return (
17
+ <div className="flex flex-col">
18
+ <div className="mb-1">
19
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Color</label>
20
+ <div className="flex gap-0.5">
21
+ <input
22
+ type="color"
23
+ className="h-5 w-5 bg-transparent border-none cursor-pointer"
24
+ value={component.properties.color}
25
+ onChange={e => onUpdate({ 'color': e.target.value })}
26
+ />
27
+ <input
28
+ type="text"
29
+ className="flex-1 bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
30
+ value={component.properties.color}
31
+ onChange={e => onUpdate({ 'color': e.target.value })}
32
+ />
33
+ </div>
34
+ </div>
35
+ <div className="flex items-center gap-1 mb-1">
36
+ <input
37
+ type="checkbox"
38
+ className="w-3 h-3"
39
+ checked={component.properties.wireframe || false}
40
+ onChange={e => onUpdate({ 'wireframe': e.target.checked })}
41
+ />
42
+ <label className="text-[9px] text-cyan-400/60">Wireframe</label>
43
+ </div>
44
+
45
+ <div>
46
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Texture</label>
47
+ <div className="max-h-32 overflow-y-auto">
48
+ <TextureListViewer
49
+ files={textureFiles}
50
+ selected={component.properties.texture || undefined}
51
+ onSelect={(file) => onUpdate({ 'texture': file })}
52
+ basePath={basePath}
53
+ />
54
+ </div>
55
+ </div>
56
+
57
+ {component.properties.texture && (
58
+ <div className="border-t border-cyan-500/20 pt-1 mt-1">
59
+ <div className="flex items-center gap-1 mb-1">
60
+ <input
61
+ type="checkbox"
62
+ className="w-3 h-3"
63
+ checked={component.properties.repeat || false}
64
+ onChange={e => onUpdate({ 'repeat': e.target.checked })}
65
+ />
66
+ <label className="text-[9px] text-cyan-400/60">Repeat Texture</label>
67
+ </div>
68
+
69
+ {component.properties.repeat && (
70
+ <div>
71
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Repeat (X, Y)</label>
72
+ <div className="flex gap-0.5">
73
+ <input
74
+ type="number"
75
+ className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
76
+ value={component.properties.repeatCount?.[0] ?? 1}
77
+ onChange={e => {
78
+ const y = component.properties.repeatCount?.[1] ?? 1;
79
+ onUpdate({ 'repeatCount': [parseFloat(e.target.value), y] });
80
+ }}
81
+ />
82
+ <input
83
+ type="number"
84
+ className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
85
+ value={component.properties.repeatCount?.[1] ?? 1}
86
+ onChange={e => {
87
+ const x = component.properties.repeatCount?.[0] ?? 1;
88
+ onUpdate({ 'repeatCount': [x, parseFloat(e.target.value)] });
89
+ }}
90
+ />
91
+ </div>
92
+ </div>
93
+ )}
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ };
99
+
100
+
101
+ import { useMemo } from 'react';
102
+ import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, Texture } from 'three';
103
+
104
+ // View for Material component
105
+ function MaterialComponentView({ properties, loadedTextures, isSelected }: { properties: any, loadedTextures?: Record<string, Texture>, isSelected?: boolean }) {
106
+ const textureName = properties?.texture;
107
+ const repeat = properties?.repeat;
108
+ const repeatCount = properties?.repeatCount;
109
+ const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
110
+
111
+ const finalTexture = useMemo(() => {
112
+ if (!texture) return undefined;
113
+ const t = texture.clone();
114
+ if (repeat) {
115
+ t.wrapS = t.wrapT = RepeatWrapping;
116
+ if (repeatCount) t.repeat.set(repeatCount[0], repeatCount[1]);
117
+ } else {
118
+ t.wrapS = t.wrapT = ClampToEdgeWrapping;
119
+ t.repeat.set(1, 1);
120
+ }
121
+ t.colorSpace = SRGBColorSpace;
122
+ t.needsUpdate = true;
123
+ return t;
124
+ }, [texture, repeat, repeatCount?.[0], repeatCount?.[1]]);
125
+
126
+ if (!properties) {
127
+ return <meshStandardMaterial color="red" wireframe />;
128
+ }
129
+
130
+ const { color, wireframe = false } = properties;
131
+ const displayColor = isSelected ? "cyan" : color;
132
+
133
+ return <meshStandardMaterial
134
+ key={finalTexture?.uuid ?? 'no-texture'}
135
+ color={displayColor}
136
+ wireframe={wireframe}
137
+ map={finalTexture}
138
+ transparent={!!finalTexture}
139
+ side={DoubleSide}
140
+ />;
141
+ }
142
+
143
+ const MaterialComponent: Component = {
144
+ name: 'Material',
145
+ Editor: MaterialComponentEditor,
146
+ View: MaterialComponentView,
147
+ defaultProperties: {
148
+ color: '#ffffff',
149
+ wireframe: false
150
+ }
151
+ };
152
+
153
+ export default MaterialComponent;