morfeo 0.2.0 → 0.3.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.
@@ -0,0 +1,489 @@
1
+ // Kit diorama: maqueta 3D low-poly flotante con corte de tierra, personajes, tráfico, rayos X y modelos .glb.
2
+ // Va dentro de un <Lienzo3D>. Todo depende de `t` (segundos): nada corre solo.
3
+ //
4
+ // <Maqueta ancho={20} fondo={14} /> isla con estratos, pasto y flores
5
+ // <Modelo src={props.coche.src} pos={[0, 0, 2]} tam={2} animacion t={t} /> un .glb (o un recurso resuelto)
6
+ // <Persona pos={[1, 0, 3]} pose="saludar" t={t} /> trabajador low-poly
7
+ // <Trafico camino={[[-9, 0, 2], [9, 0, 2]]} n={8} t={t} pluma={{d: 9, cerrada: [[2, 6]]}} />
8
+ // <Corte k={abrir}> <Tinaco /> </Corte> rayos X: abre lo que envuelve
9
+ import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
10
+ import {continueRender, delayRender} from 'remotion';
11
+ import * as THREE from 'three';
12
+ import {clone as clonarEsqueleto} from 'three/examples/jsm/utils/SkeletonUtils.js';
13
+ import {cargarGltf} from '../motor/modelo3d';
14
+ import {recurso} from '../motor/util';
15
+ import {azarN} from './ilustrado';
16
+ import {Piedras} from './tres';
17
+
18
+ type V3 = [number, number, number];
19
+ type Pieza = {pos?: V3; rot?: V3; escala?: number};
20
+ const RAD = Math.PI / 180;
21
+ const grupo = (p: Pieza) => ({position: p.pos ?? ([0, 0, 0] as V3), rotation: (p.rot ?? [0, 0, 0]).map((g) => g * RAD) as V3, scale: p.escala ?? 1});
22
+ const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
23
+ const suave = (x: number) => {
24
+ const c = clamp01(x);
25
+ return c * c * (3 - 2 * c);
26
+ };
27
+
28
+ /**
29
+ * Maqueta flotante: una isla de `ancho`×`fondo` con estratos de tierra a la vista en los cuatro lados, pasto, flores,
30
+ * matas y piedritas. La superficie queda en y = 0. `hueco` deja un rectángulo sin pasto (para río, calle, alberca).
31
+ */
32
+ export const Maqueta: React.FC<
33
+ Pieza & {
34
+ ancho?: number;
35
+ fondo?: number;
36
+ capas?: [string, number][];
37
+ pasto?: string;
38
+ flores?: number;
39
+ matas?: number;
40
+ colores?: string[];
41
+ semilla?: number;
42
+ /** Zonas sin flores ni matas: [x, z, ancho, fondo]. */
43
+ libres?: [number, number, number, number][];
44
+ }
45
+ > = ({ancho = 20, fondo = 14, capas = [['#79C24F', 0.4], ['#9A6A45', 1.6], ['#7D553A', 1.4], ['#8C8580', 1.2]], pasto, flores = 60, matas = 140, colores = ['#B57BE8', '#FFD23F', '#FF6B6B', '#FFFFFF'], semilla = 0, libres = [], ...p}) => {
46
+ const libre = (x: number, z: number) => libres.some(([lx, lz, la, lf]) => Math.abs(x - lx) < la / 2 && Math.abs(z - lz) < lf / 2);
47
+ const total = capas.reduce((a, [, g]) => a + g, 0);
48
+ const refMatas = useRef<THREE.InstancedMesh>(null);
49
+ const refFlores = useRef<THREE.InstancedMesh>(null);
50
+ const refTallos = useRef<THREE.InstancedMesh>(null);
51
+ const puntos = (n: number, s: number) =>
52
+ Array.from({length: n}, (_, i) => [(azarN(s + i * 2) - 0.5) * (ancho - 0.6), (azarN(s + i * 2 + 1) - 0.5) * (fondo - 0.6)] as [number, number]).filter(([x, z]) => !libre(x, z));
53
+ useLayoutEffect(() => {
54
+ const o = new THREE.Object3D();
55
+ const m = refMatas.current;
56
+ if (m) {
57
+ const lista = puntos(matas, semilla + 11);
58
+ lista.forEach(([x, z], i) => {
59
+ o.position.set(x, 0.12, z);
60
+ o.rotation.set(0, azarN(semilla + i * 13) * 6, 0);
61
+ const k = 0.6 + azarN(semilla + i * 7) * 0.8;
62
+ o.scale.set(k, k * (1 + azarN(semilla + i) * 0.8), k);
63
+ o.updateMatrix();
64
+ m.setMatrixAt(i, o.matrix);
65
+ });
66
+ m.count = lista.length;
67
+ m.instanceMatrix.needsUpdate = true;
68
+ }
69
+ const f = refFlores.current;
70
+ const tl = refTallos.current;
71
+ if (f && tl) {
72
+ const lista = puntos(flores, semilla + 97);
73
+ const c = new THREE.Color();
74
+ lista.forEach(([x, z], i) => {
75
+ const alto = 0.25 + azarN(semilla + i * 5) * 0.25;
76
+ o.position.set(x, alto / 2, z);
77
+ o.rotation.set(0, 0, 0);
78
+ o.scale.set(1, alto / 0.4, 1);
79
+ o.updateMatrix();
80
+ tl.setMatrixAt(i, o.matrix);
81
+ o.position.set(x, alto + 0.05, z);
82
+ o.scale.setScalar(0.8 + azarN(semilla + i * 3) * 0.6);
83
+ o.updateMatrix();
84
+ f.setMatrixAt(i, o.matrix);
85
+ f.setColorAt(i, c.set(colores[i % colores.length]));
86
+ });
87
+ f.count = lista.length;
88
+ tl.count = lista.length;
89
+ f.instanceMatrix.needsUpdate = true;
90
+ tl.instanceMatrix.needsUpdate = true;
91
+ if (f.instanceColor) f.instanceColor.needsUpdate = true;
92
+ }
93
+ }, [ancho, fondo, matas, flores, semilla, JSON.stringify(libres), colores.join()]);
94
+ let y = 0;
95
+ return (
96
+ <group {...grupo(p)}>
97
+ {capas.map(([color, grosor], i) => {
98
+ const centro = y - grosor / 2;
99
+ y -= grosor;
100
+ return (
101
+ <mesh key={i} position={[0, centro, 0]} receiveShadow castShadow={i === 0}>
102
+ <boxGeometry args={[ancho, grosor, fondo]} />
103
+ <meshStandardMaterial color={i === 0 && pasto ? pasto : color} roughness={0.95} />
104
+ </mesh>
105
+ );
106
+ })}
107
+ {/* piedritas en las cuatro caras */}
108
+ <Piedras z={fondo / 2 + 0.01} ancho={ancho} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(ancho * 2.4)} semilla={semilla + 3} />
109
+ <group rotation={[0, Math.PI / 2, 0]}>
110
+ <Piedras z={ancho / 2 + 0.01} ancho={fondo} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(fondo * 2.4)} semilla={semilla + 41} />
111
+ </group>
112
+ <group rotation={[0, -Math.PI / 2, 0]}>
113
+ <Piedras z={ancho / 2 + 0.01} ancho={fondo} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(fondo * 2.4)} semilla={semilla + 77} />
114
+ </group>
115
+ <instancedMesh ref={refMatas} args={[undefined as any, undefined as any, matas]} castShadow frustumCulled={false}>
116
+ <coneGeometry args={[0.07, 0.32, 4]} />
117
+ <meshStandardMaterial color={pasto ?? '#5FAE3C'} roughness={0.9} flatShading />
118
+ </instancedMesh>
119
+ <instancedMesh ref={refTallos} args={[undefined as any, undefined as any, flores]} frustumCulled={false}>
120
+ <cylinderGeometry args={[0.018, 0.018, 0.4, 4]} />
121
+ <meshStandardMaterial color="#3E8A35" roughness={0.9} />
122
+ </instancedMesh>
123
+ <instancedMesh ref={refFlores} args={[undefined as any, undefined as any, flores]} castShadow frustumCulled={false}>
124
+ <icosahedronGeometry args={[0.09, 0]} />
125
+ <meshStandardMaterial roughness={0.7} flatShading />
126
+ </instancedMesh>
127
+ </group>
128
+ );
129
+ };
130
+
131
+ /**
132
+ * Un modelo .glb dentro del lienzo: tuyo (`src: 'castor.glb'` en assets/) o un recurso de la biblioteca
133
+ * (`src: props.coche`, lo que deja `{tipo: 'recurso', buscar: 'coche'}` en las props del componente).
134
+ * Se escala para que su lado mayor mida `tam` y se apoya en y = 0. `animacion` reproduce los clips del archivo
135
+ * sincronizados con `t` (true = todos, o el nombre de uno).
136
+ */
137
+ export const Modelo: React.FC<Pieza & {src: string | {src: string}; tam?: number; t?: number; animacion?: boolean | string; velocidad?: number; color?: string; sombras?: boolean}> = ({src, tam = 1, t = 0, animacion, velocidad = 1, color, sombras = true, ...p}) => {
138
+ const ruta = typeof src === 'string' ? src : src.src;
139
+ const url = recurso(ruta);
140
+ const [g, setG] = useState<{obj: THREE.Object3D; mixer: THREE.AnimationMixer | null} | null>(null);
141
+ const [handle] = useState(() => delayRender(`Cargando modelo ${ruta}`));
142
+ useEffect(() => {
143
+ cargarGltf(url)
144
+ .then((gl) => {
145
+ const copia = clonarEsqueleto(gl.scene) as THREE.Object3D;
146
+ const caja = new THREE.Box3().setFromObject(copia);
147
+ const t3 = caja.getSize(new THREE.Vector3());
148
+ const k = tam / Math.max(t3.x, t3.y, t3.z, 0.0001);
149
+ const centro = caja.getCenter(new THREE.Vector3());
150
+ const envoltura = new THREE.Group();
151
+ copia.position.set(-centro.x, -caja.min.y, -centro.z);
152
+ envoltura.add(copia);
153
+ envoltura.scale.setScalar(k);
154
+ copia.traverse((o: any) => {
155
+ if (o.isMesh) {
156
+ o.castShadow = sombras;
157
+ o.receiveShadow = sombras;
158
+ if (color) {
159
+ o.material = o.material.clone();
160
+ o.material.color = new THREE.Color(color);
161
+ }
162
+ }
163
+ });
164
+ let mixer: THREE.AnimationMixer | null = null;
165
+ if (animacion && gl.animations.length) {
166
+ mixer = new THREE.AnimationMixer(copia);
167
+ const clips = animacion === true ? gl.animations : gl.animations.filter((a) => a.name === animacion || a.name.toLowerCase().includes(String(animacion).toLowerCase()));
168
+ for (const clip of clips.length ? clips : gl.animations.slice(0, 1)) mixer.clipAction(clip).play();
169
+ }
170
+ setG({obj: envoltura, mixer});
171
+ continueRender(handle);
172
+ })
173
+ .catch((e) => {
174
+ continueRender(handle);
175
+ throw new Error(`No pude cargar el modelo ${ruta}: ${e.message}`);
176
+ });
177
+ }, [url, handle, tam, color, String(animacion)]);
178
+ useLayoutEffect(() => {
179
+ g?.mixer?.setTime(Math.max(0, t) * velocidad);
180
+ });
181
+ return <group {...grupo(p)}>{g ? <primitive object={g.obj} /> : null}</group>;
182
+ };
183
+
184
+ const MAT = (color: string, r = 0.8) => <meshStandardMaterial color={color} roughness={r} flatShading />;
185
+
186
+ /**
187
+ * Persona low-poly (trabajador con casco y chaleco por defecto). Mide ~1.6 de alto con `escala` 1.
188
+ * `pose`: 'parado' | 'saludar' | 'caminar' | 'senalar' | 'celebrar'. `t` mueve brazos y piernas.
189
+ */
190
+ export const Persona: React.FC<Pieza & {t?: number; pose?: 'parado' | 'saludar' | 'caminar' | 'senalar' | 'señalar' | 'celebrar'; piel?: string; ropa?: string; chaleco?: string | false; casco?: string | false; pantalon?: string}> = ({
191
+ t = 0,
192
+ pose = 'parado',
193
+ piel = '#F2C19B',
194
+ ropa = '#3A6EC8',
195
+ chaleco = '#FF7A1A',
196
+ casco = '#FFC928',
197
+ pantalon = '#2E3A59',
198
+ ...p
199
+ }) => {
200
+ const paso = pose === 'caminar' ? Math.sin(t * 9) * 0.6 : 0;
201
+ const respira = Math.sin(t * 2.2) * 0.015;
202
+ let brazoI = 0.15;
203
+ let brazoD = -0.15;
204
+ if (pose === 'saludar') brazoD = -2.6 + Math.sin(t * 10) * 0.35;
205
+ if (pose === 'celebrar') {
206
+ brazoI = 2.7 + Math.sin(t * 9) * 0.2;
207
+ brazoD = -2.7 - Math.sin(t * 9) * 0.2;
208
+ }
209
+ if (pose === 'senalar' || pose === 'señalar') brazoD = -1.55;
210
+ if (pose === 'caminar') {
211
+ brazoI = -paso * 0.8;
212
+ brazoD = paso * 0.8;
213
+ }
214
+ const salto = pose === 'celebrar' ? Math.abs(Math.sin(t * 9)) * 0.12 : pose === 'caminar' ? Math.abs(Math.sin(t * 9)) * 0.04 : 0;
215
+ const brazo = (lado: 1 | -1, ang: number, adelante = 0) => (
216
+ <group position={[0.3 * lado, 1.18, 0]} rotation={[pose === 'caminar' ? ang : adelante, 0, pose === 'caminar' ? 0 : ang]}>
217
+ <mesh position={[0, -0.26, 0]} castShadow>
218
+ <boxGeometry args={[0.14, 0.52, 0.14]} />
219
+ {MAT(chaleco ? ropa : ropa)}
220
+ </mesh>
221
+ <mesh position={[0, -0.56, 0]} castShadow>
222
+ <sphereGeometry args={[0.085, 8, 6]} />
223
+ {MAT(piel)}
224
+ </mesh>
225
+ </group>
226
+ );
227
+ return (
228
+ <group {...grupo(p)}>
229
+ <group position={[0, salto + respira, 0]}>
230
+ {/* piernas */}
231
+ {[-1, 1].map((l) => (
232
+ <group key={l} position={[0.12 * l, 0.62, 0]} rotation={[l * paso, 0, 0]}>
233
+ <mesh position={[0, -0.3, 0]} castShadow>
234
+ <boxGeometry args={[0.17, 0.6, 0.18]} />
235
+ {MAT(pantalon)}
236
+ </mesh>
237
+ <mesh position={[0, -0.6, 0.05]} castShadow>
238
+ <boxGeometry args={[0.19, 0.08, 0.28]} />
239
+ {MAT('#3B2A20')}
240
+ </mesh>
241
+ </group>
242
+ ))}
243
+ {/* torso y chaleco */}
244
+ <mesh position={[0, 0.95, 0]} castShadow>
245
+ <boxGeometry args={[0.48, 0.62, 0.28]} />
246
+ {MAT(ropa)}
247
+ </mesh>
248
+ {chaleco ? (
249
+ <mesh position={[0, 0.93, 0]} castShadow>
250
+ <boxGeometry args={[0.5, 0.5, 0.3]} />
251
+ {MAT(chaleco)}
252
+ </mesh>
253
+ ) : null}
254
+ {chaleco ? (
255
+ <mesh position={[0, 0.88, 0.152]}>
256
+ <boxGeometry args={[0.5, 0.06, 0.01]} />
257
+ <meshStandardMaterial color="#E8F2F5" roughness={0.3} emissive="#ffffff" emissiveIntensity={0.15} />
258
+ </mesh>
259
+ ) : null}
260
+ {brazo(-1, brazoI)}
261
+ {brazo(1, brazoD, pose === 'senalar' || pose === 'señalar' ? -1.4 : 0)}
262
+ {/* cabeza */}
263
+ <mesh position={[0, 1.42, 0]} castShadow>
264
+ <sphereGeometry args={[0.22, 12, 10]} />
265
+ {MAT(piel, 0.7)}
266
+ </mesh>
267
+ {[-1, 1].map((l) => (
268
+ <mesh key={l} position={[0.08 * l, 1.45, 0.2]}>
269
+ <sphereGeometry args={[0.028, 6, 6]} />
270
+ <meshStandardMaterial color="#1B1B1F" />
271
+ </mesh>
272
+ ))}
273
+ {casco ? (
274
+ <group position={[0, 1.55, 0]}>
275
+ <mesh castShadow>
276
+ <sphereGeometry args={[0.24, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
277
+ {MAT(casco, 0.5)}
278
+ </mesh>
279
+ <mesh position={[0, 0, 0.06]}>
280
+ <cylinderGeometry args={[0.3, 0.3, 0.035, 14]} />
281
+ {MAT(casco, 0.5)}
282
+ </mesh>
283
+ </group>
284
+ ) : null}
285
+ </group>
286
+ </group>
287
+ );
288
+ };
289
+
290
+ /** Coche low-poly (2 de largo). Para tráfico sin descargar nada. */
291
+ export const Coche: React.FC<Pieza & {color?: string; techo?: string; t?: number; rueda?: number}> = ({color = '#E8533B', techo = '#1F3550', t = 0, ...p}) => (
292
+ <group {...grupo(p)}>
293
+ <mesh position={[0, 0.38, 0]} castShadow receiveShadow>
294
+ <boxGeometry args={[2, 0.45, 0.95]} />
295
+ {MAT(color, 0.5)}
296
+ </mesh>
297
+ <mesh position={[-0.1, 0.78, 0]} castShadow>
298
+ <boxGeometry args={[1.1, 0.38, 0.86]} />
299
+ {MAT(techo, 0.4)}
300
+ </mesh>
301
+ {[
302
+ [0.65, 0.48],
303
+ [-0.65, 0.48],
304
+ [0.65, -0.48],
305
+ [-0.65, -0.48],
306
+ ].map(([x, z], i) => (
307
+ <mesh key={i} position={[x, 0.18, z]} rotation={[Math.PI / 2, t * 6, 0]} castShadow>
308
+ <cylinderGeometry args={[0.19, 0.19, 0.14, 10]} />
309
+ {MAT('#1E1E22', 0.9)}
310
+ </mesh>
311
+ ))}
312
+ {[0.2, -0.2].map((z, i) => (
313
+ <mesh key={i} position={[1.005, 0.42, z * 1.6]}>
314
+ <boxGeometry args={[0.02, 0.1, 0.16]} />
315
+ <meshStandardMaterial color="#FFF6C8" emissive="#FFF2B0" emissiveIntensity={0.6} />
316
+ </mesh>
317
+ ))}
318
+ </group>
319
+ );
320
+
321
+ const COLORES_COCHE = ['#E8533B', '#2D7BFF', '#2BB673', '#F5B841', '#8E5BD9', '#FF7EB6', '#F2F2F2', '#33363D'];
322
+
323
+ /**
324
+ * Tráfico: `n` coches (o lo que dibuje `pieza`) que avanzan por `camino` a `velocidad` (u/s) con `separacion`.
325
+ * `pluma`: una barrera a `d` unidades del inicio del camino; mientras está cerrada (intervalos [desde, hasta] en
326
+ * segundos) los coches que no la han cruzado se detienen y hacen fila. Todo se calcula de `t`: es determinista.
327
+ */
328
+ export const Trafico: React.FC<{
329
+ camino: V3[];
330
+ t: number;
331
+ n?: number;
332
+ velocidad?: number;
333
+ separacion?: number;
334
+ desfase?: number;
335
+ pluma?: {d: number; cerrada: [number, number][]};
336
+ colores?: string[];
337
+ pieza?: (i: number, color: string, t: number) => React.ReactNode;
338
+ semilla?: number;
339
+ }> = ({camino, t, n = 8, velocidad = 2.2, separacion = 2.8, desfase = 0, pluma, colores = COLORES_COCHE, pieza, semilla = 0}) => {
340
+ const curva = useMemo(() => new THREE.CatmullRomCurve3(camino.map((p) => new THREE.Vector3(...p)), false, 'catmullrom', 0.02), [JSON.stringify(camino)]);
341
+ const L = curva.getLength();
342
+ // posición libre de cada coche (sin pluma), sin dar la vuelta: el coche 0 va adelante
343
+ const libre = (i: number, tt: number) => velocidad * tt + desfase - i * separacion;
344
+ const filas = (pluma?.cerrada ?? []).map(() => 0); // cuántos coches hacen fila en cada cierre
345
+ const pos: number[] = [];
346
+ for (let i = 0; i < n; i++) {
347
+ let retraso = 0; // segundos que este coche lleva detenido
348
+ if (pluma)
349
+ pluma.cerrada.forEach(([c0, c1], k) => {
350
+ const enCierre = libre(i, c0) - retraso * velocidad;
351
+ // la siguiente pluma por delante (la pluma está en d, d + L, d + 2L... porque el camino se repite)
352
+ const pl = pluma.d + Math.ceil((enCierre - pluma.d) / L) * L;
353
+ const lugar = pl - filas[k] * separacion;
354
+ const llega = c0 + Math.max(0, (lugar - enCierre) / velocidad);
355
+ if (llega >= c1) return; // llega cuando ya abrió
356
+ filas[k]++;
357
+ if (t > llega) retraso += Math.min(t, c1) - llega;
358
+ });
359
+ let s = libre(i, t) - retraso * velocidad;
360
+ if (pos.length) s = Math.min(s, pos[pos.length - 1] - separacion);
361
+ pos.push(s);
362
+ }
363
+ return (
364
+ <group>
365
+ {pos.map((s, i) => {
366
+ const u = ((s % L) + L) % L / L;
367
+ const p = curva.getPointAt(u);
368
+ const d = curva.getTangentAt(u);
369
+ const ang = Math.atan2(-d.z, d.x);
370
+ const color = colores[(i + semilla) % colores.length];
371
+ // aparecen y desaparecen encogiéndose en los extremos del camino (no saltan de un lado a otro)
372
+ const borde = suave(Math.min(u * L, (1 - u) * L) / (separacion * 0.45));
373
+ if (borde <= 0.01) return null;
374
+ return (
375
+ <group key={i} position={p} rotation={[0, ang, 0]} scale={borde}>
376
+ {pieza ? pieza(i, color, t) : <Coche color={color} t={t} />}
377
+ </group>
378
+ );
379
+ })}
380
+ </group>
381
+ );
382
+ };
383
+
384
+ /** Pluma (barrera) de caseta: `abierta` 0..1 la levanta. El brazo mide `largo` y apunta a +x. */
385
+ export const Pluma: React.FC<Pieza & {abierta?: number; largo?: number}> = ({abierta = 0, largo = 3.2, ...p}) => (
386
+ <group {...grupo(p)}>
387
+ <mesh position={[0, 0.5, 0]} castShadow>
388
+ <boxGeometry args={[0.4, 1, 0.4]} />
389
+ {MAT('#FFC928', 0.5)}
390
+ </mesh>
391
+ <group position={[0, 0.85, 0]} rotation={[0, 0, (suave(abierta) * 80 * Math.PI) / 180]}>
392
+ {Array.from({length: 8}, (_, i) => (
393
+ <mesh key={i} position={[(i + 0.5) * (largo / 8), 0, 0]} castShadow>
394
+ <boxGeometry args={[largo / 8, 0.12, 0.12]} />
395
+ {MAT(i % 2 ? '#FFFFFF' : '#E5484D', 0.5)}
396
+ </mesh>
397
+ ))}
398
+ </group>
399
+ </group>
400
+ );
401
+
402
+ /**
403
+ * Rayos X: recorta lo que envuelve con un plano para ver adentro. `k` (0..1) abre el corte (anímalo con el tiempo).
404
+ * `eje`: hacia dónde se abre ('z' quita la mitad de enfrente). Lo que va adentro (agua, flotador) ponlo fuera de <Corte>.
405
+ */
406
+ export const Corte: React.FC<{k: number; eje?: 'x' | 'y' | 'z'; centro?: number; tam?: number; children: React.ReactNode}> = ({k, eje = 'z', centro = 0, tam = 2, children}) => {
407
+ const ref = useRef<THREE.Group>(null);
408
+ const plano = useMemo(() => new THREE.Plane(), []);
409
+ useLayoutEffect(() => {
410
+ const n = eje === 'x' ? new THREE.Vector3(-1, 0, 0) : eje === 'y' ? new THREE.Vector3(0, -1, 0) : new THREE.Vector3(0, 0, -1);
411
+ // k = 0: el plano queda fuera (no recorta); k = 1: corta por `centro`
412
+ const d = centro + (1 - suave(k)) * tam;
413
+ const mundo = new THREE.Vector3();
414
+ ref.current?.getWorldPosition(mundo);
415
+ const off = eje === 'x' ? mundo.x : eje === 'y' ? mundo.y : mundo.z;
416
+ plano.set(n, d + off);
417
+ ref.current?.traverse((o: any) => {
418
+ if (!o.isMesh) return;
419
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
420
+ for (const m of mats) {
421
+ m.clippingPlanes = k > 0 ? [plano] : null;
422
+ m.side = THREE.DoubleSide;
423
+ }
424
+ });
425
+ });
426
+ return <group ref={ref}>{children}</group>;
427
+ };
428
+
429
+ /** Gotas de agua que caen de `de` a `a` (por un tubo transparente o de una llave). */
430
+ export const Gotas: React.FC<{de: V3; a: V3; t: number; n?: number; velocidad?: number; color?: string; tam?: number}> = ({de, a, t, n = 6, velocidad = 1.4, color = '#4FB3FF', tam = 0.09}) => (
431
+ <group>
432
+ {Array.from({length: n}, (_, i) => {
433
+ const k = (t * velocidad + i / n) % 1;
434
+ return (
435
+ <mesh key={i} position={[de[0] + (a[0] - de[0]) * k, de[1] + (a[1] - de[1]) * k, de[2] + (a[2] - de[2]) * k]} scale={[1, 1.4, 1]}>
436
+ <sphereGeometry args={[tam, 8, 6]} />
437
+ <meshStandardMaterial color={color} roughness={0.15} emissive={color} emissiveIntensity={0.35} transparent opacity={0.9} />
438
+ </mesh>
439
+ );
440
+ })}
441
+ </group>
442
+ );
443
+
444
+ /** Confeti 3D que estalla en `pos` cuando `t` pasa de `en`. */
445
+ export const Confeti3D: React.FC<{pos: V3; t: number; en: number; n?: number; colores?: string[]}> = ({pos, t, en, n = 60, colores = ['#FFC400', '#FF5A8A', '#4FB3FF', '#5ADC6E', '#FFFFFF']}) => {
446
+ const dt = t - en;
447
+ if (dt < 0 || dt > 3) return null;
448
+ return (
449
+ <group position={pos}>
450
+ {Array.from({length: n}, (_, i) => {
451
+ const ang = azarN(i) * Math.PI * 2;
452
+ const v = 2 + azarN(i * 3) * 3;
453
+ const vy = 4 + azarN(i * 5) * 4;
454
+ const x = Math.cos(ang) * v * dt;
455
+ const z = Math.sin(ang) * v * dt;
456
+ const y = vy * dt - 4.9 * dt * dt * 0.6;
457
+ return (
458
+ <mesh key={i} position={[x, y, z]} rotation={[dt * 8 + i, dt * 6, i]}>
459
+ <boxGeometry args={[0.12, 0.02, 0.07]} />
460
+ <meshStandardMaterial color={colores[i % colores.length]} roughness={0.5} />
461
+ </mesh>
462
+ );
463
+ })}
464
+ </group>
465
+ );
466
+ };
467
+
468
+ /** Linterna: un foco con su cono visible, de `pos` hacia `mirar` (escenas de noche). */
469
+ export const Linterna: React.FC<{pos: V3; mirar: V3; color?: string; fuerza?: number; angulo?: number}> = ({pos, mirar, color = '#FFF1C2', fuerza = 6, angulo = 0.38}) => {
470
+ const objetivo = useMemo(() => new THREE.Object3D(), []);
471
+ useLayoutEffect(() => {
472
+ objetivo.position.set(...mirar);
473
+ objetivo.updateMatrixWorld();
474
+ });
475
+ const dir = new THREE.Vector3(...mirar).sub(new THREE.Vector3(...pos));
476
+ const largo = dir.length();
477
+ const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir.clone().normalize());
478
+ const medio = new THREE.Vector3(...pos).add(dir.clone().multiplyScalar(0.5));
479
+ return (
480
+ <group>
481
+ <primitive object={objetivo} />
482
+ <spotLight position={pos} target={objetivo} angle={angulo} penumbra={0.6} decay={0} intensity={fuerza} color={color} castShadow />
483
+ <mesh position={medio} quaternion={q}>
484
+ <coneGeometry args={[Math.tan(angulo) * largo, largo, 32, 1, true]} />
485
+ <meshBasicMaterial color={color} transparent opacity={0.09} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
486
+ </mesh>
487
+ </group>
488
+ );
489
+ };
@@ -4,3 +4,4 @@
4
4
  export * from './ilustrado';
5
5
  export * from './tres';
6
6
  export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';
7
+ export * from './diorama';
@@ -26,7 +26,20 @@ const mezcla = (a: V3, b: V3, k: number): V3 => [a[0] + (b[0] - a[0]) * k, a[1]
26
26
  * escena; sin `a` se acerca un poco sola (`deriva`, 0.06 = 6 % del camino hacia lo que mira). `corte` salta sin viaje.
27
27
  */
28
28
  export type Toma3D = {pos: V3; mirar?: V3; fov?: number; a?: {pos?: V3; mirar?: V3; fov?: number}; viaje?: number; corte?: boolean; deriva?: number};
29
- type Estado = {pos: V3; mirar: V3; fov: number};
29
+ export type Estado = {pos: V3; mirar: V3; fov: number};
30
+
31
+ /**
32
+ * Dónde cae un punto 3D en la pantalla (1080×1920) con la cámara de este cuadro. Devuelve {x, y, visible}:
33
+ * `visible` es falso si el punto queda detrás de la cámara. Sirve para pegar etiquetas 2D a objetos 3D.
34
+ */
35
+ export function proyectar(estado: Estado, punto: V3, ancho = 1080, alto = 1920): {x: number; y: number; visible: boolean} {
36
+ const cam = new THREE.PerspectiveCamera(estado.fov, ancho / alto, 0.1, 400);
37
+ cam.position.set(...estado.pos);
38
+ cam.lookAt(...estado.mirar);
39
+ cam.updateMatrixWorld(true);
40
+ const v = new THREE.Vector3(...punto).project(cam);
41
+ return {x: ((v.x + 1) / 2) * ancho, y: ((1 - v.y) / 2) * alto, visible: v.z < 1 && v.z > -1};
42
+ }
30
43
 
31
44
  /** Cámara del cuadro actual. `tomas[i]` es la de la escena i+1; si falta, se conserva la anterior. */
32
45
  export function camara3D(tomas: (Toma3D | undefined | null)[], escena: number, tEscena: number, inicios?: number[]): Estado {
@@ -85,10 +98,104 @@ const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
85
98
  return null;
86
99
  };
87
100
 
101
+ /** Luz del cielo según la hora (0–24): mediodía claro, atardecer naranja, noche azul con luna. */
102
+ type Cielo = {fondo: string; sol: string; fuerzaSol: number; arriba: string; abajo: string; fuerzaHemi: number; entorno: number; altura: number};
103
+ const CIELOS: [number, Cielo][] = [
104
+ [0, {fondo: '#121A33', sol: '#9DB4FF', fuerzaSol: 0.55, arriba: '#5A6FA8', abajo: '#1A1F2E', fuerzaHemi: 0.5, entorno: 0.18, altura: 0.6}],
105
+ [6, {fondo: '#F6C7A0', sol: '#FFB27A', fuerzaSol: 1.4, arriba: '#FFE1C2', abajo: '#9C8A70', fuerzaHemi: 0.8, entorno: 0.4, altura: 0.25}],
106
+ [9, {fondo: '#BFE3FF', sol: '#FFFFFF', fuerzaSol: 2.3, arriba: '#FFFFFF', abajo: '#B9A98A', fuerzaHemi: 1.05, entorno: 0.55, altura: 1}],
107
+ [16, {fondo: '#BFE3FF', sol: '#FFFFFF', fuerzaSol: 2.3, arriba: '#FFFFFF', abajo: '#B9A98A', fuerzaHemi: 1.05, entorno: 0.55, altura: 1}],
108
+ [18.5, {fondo: '#F4A26B', sol: '#FF8A3D', fuerzaSol: 1.8, arriba: '#FFD2A8', abajo: '#8A6A55', fuerzaHemi: 0.75, entorno: 0.38, altura: 0.22}],
109
+ [20.5, {fondo: '#2A2F55', sol: '#8FA4FF', fuerzaSol: 0.6, arriba: '#6474B8', abajo: '#20233A', fuerzaHemi: 0.55, entorno: 0.2, altura: 0.6}],
110
+ [24, {fondo: '#121A33', sol: '#9DB4FF', fuerzaSol: 0.55, arriba: '#5A6FA8', abajo: '#1A1F2E', fuerzaHemi: 0.5, entorno: 0.18, altura: 0.6}],
111
+ ];
112
+ const HORAS: Record<string, number> = {amanecer: 6.5, manana: 10, mañana: 10, dia: 12, día: 12, tarde: 16, atardecer: 18.5, noche: 22};
113
+ const colorMezcla = (a: string, b: string, k: number) => '#' + new THREE.Color(a).lerp(new THREE.Color(b), k).getHexString();
114
+ /** Cielo y luces para una hora (número 0–24 o 'dia', 'atardecer', 'noche'...). */
115
+ export function cieloDeHora(hora: number | string): Cielo {
116
+ const h = (((typeof hora === 'string' ? HORAS[hora] ?? 12 : hora) % 24) + 24) % 24;
117
+ let i = CIELOS.findIndex(([x]) => x > h);
118
+ if (i <= 0) i = CIELOS.length - 1;
119
+ const [h0, a] = CIELOS[i - 1];
120
+ const [h1, b] = CIELOS[i];
121
+ const k = suave((h - h0) / (h1 - h0));
122
+ const n = (x: number, y: number) => x + (y - x) * k;
123
+ return {fondo: colorMezcla(a.fondo, b.fondo, k), sol: colorMezcla(a.sol, b.sol, k), fuerzaSol: n(a.fuerzaSol, b.fuerzaSol), arriba: colorMezcla(a.arriba, b.arriba, k), abajo: colorMezcla(a.abajo, b.abajo, k), fuerzaHemi: n(a.fuerzaHemi, b.fuerzaHemi), entorno: n(a.entorno, b.entorno), altura: n(a.altura, b.altura)};
124
+ }
125
+
126
+ /** Activa el recorte por planos (para `Corte`, la vista de rayos X). */
127
+ const Recorte: React.FC = () => {
128
+ const {gl} = useThree();
129
+ useLayoutEffect(() => {
130
+ gl.localClippingEnabled = true;
131
+ }, [gl]);
132
+ return null;
133
+ };
134
+
135
+ /**
136
+ * Etiqueta pegada a un punto 3D: pastilla de color con una flecha que apunta al objeto. Sigue al objeto cuando la
137
+ * cámara se mueve. `escena` (1, 2...) la limita a esa escena; `en`/`hasta` son segundos desde que empieza.
138
+ */
139
+ export type Etiqueta3D = {
140
+ pos: V3;
141
+ texto: string;
142
+ escena?: number | number[];
143
+ en?: number;
144
+ hasta?: number;
145
+ color?: string;
146
+ colorTexto?: string;
147
+ /** Hacia dónde sale la pastilla desde el objeto. */
148
+ lado?: 'arriba' | 'abajo' | 'izq' | 'der';
149
+ /** Largo de la flecha (px). */
150
+ largo?: number;
151
+ tam?: number;
152
+ inclinar?: number;
153
+ };
154
+
155
+ const EtiquetasPantalla: React.FC<{etiquetas: Etiqueta3D[]; estado: Estado; escena: number; tEscena: number}> = ({etiquetas, estado, escena, tEscena}) => (
156
+ <>
157
+ {etiquetas.map((e, i) => {
158
+ const escenas = e.escena == null ? null : ([] as number[]).concat(e.escena);
159
+ if (escenas && !escenas.includes(escena)) return null;
160
+ const t = tEscena - (e.en ?? 0.2);
161
+ if (t < 0 || (e.hasta != null && tEscena > e.hasta)) return null;
162
+ const p = proyectar(estado, e.pos);
163
+ if (!p.visible) return null;
164
+ const lado = e.lado ?? 'arriba';
165
+ const largo = e.largo ?? 110;
166
+ const tam = e.tam ?? 40;
167
+ const k = Math.min(1, t / 0.35);
168
+ const pop = 1 - Math.exp(-7 * k) * Math.cos(k * 7.5);
169
+ const flecha = largo * Math.min(1, t / 0.25);
170
+ const [dx, dy] = lado === 'arriba' ? [0, -1] : lado === 'abajo' ? [0, 1] : lado === 'izq' ? [-1, 0] : [1, 0];
171
+ const fx = p.x + dx * flecha;
172
+ const fy = p.y + dy * flecha;
173
+ const color = e.color ?? '#2D7BFF';
174
+ const ang = (Math.atan2(-dy, -dx) * 180) / Math.PI;
175
+ const transform = lado === 'arriba' ? 'translate(-50%, -100%)' : lado === 'abajo' ? 'translate(-50%, 0)' : lado === 'izq' ? 'translate(-100%, -50%)' : 'translate(0, -50%)';
176
+ return (
177
+ <React.Fragment key={i}>
178
+ <svg width={1080} height={1920} style={{position: 'absolute', left: 0, top: 0, pointerEvents: 'none'}}>
179
+ <line x1={fx} y1={fy} x2={p.x - dx * 6} y2={p.y - dy * 6} stroke={color} strokeWidth={9} strokeLinecap="round" />
180
+ <g transform={`translate(${p.x} ${p.y}) rotate(${ang})`}>
181
+ <path d="M 0 0 L -30 -17 L -30 17 Z" fill={color} />
182
+ </g>
183
+ </svg>
184
+ <div style={{position: 'absolute', left: fx, top: fy, transform: `${transform} scale(${pop}) rotate(${e.inclinar ?? -3}deg)`, transformOrigin: 'center', background: color, color: e.colorTexto ?? '#FFFFFF', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: tam, padding: `${tam * 0.22}px ${tam * 0.5}px`, borderRadius: tam * 0.3, border: '4px solid rgba(255,255,255,0.95)', boxShadow: '0 8px 18px rgba(0,0,0,0.3)', whiteSpace: 'nowrap', letterSpacing: 0.5}}>
185
+ {e.texto}
186
+ </div>
187
+ </React.Fragment>
188
+ );
189
+ })}
190
+ </>
191
+ );
192
+
88
193
  /**
89
194
  * El lienzo del mundo 3D (1080×1920). `luz`:
90
195
  * 'dia' cielo claro, sol con sombras suaves (maquetas, cortes de terreno, low-poly)
91
196
  * 'calida' cuarto oscuro con un foco cálido desde arriba y su cono visible (mesa, dinero, objetos)
197
+ * `hora` (0–24 o 'atardecer', 'noche'...) cambia el cielo y el sol de la luz 'dia'; anímala para pasar de día a noche.
198
+ * `etiquetas`: pastillas con flecha pegadas a puntos 3D (siguen al objeto con la cámara).
92
199
  */
93
200
  export const Lienzo3D: React.FC<{
94
201
  tomas: (Toma3D | undefined | null)[];
@@ -96,25 +203,32 @@ export const Lienzo3D: React.FC<{
96
203
  tEscena?: number;
97
204
  inicios?: number[];
98
205
  luz?: 'dia' | 'calida';
206
+ hora?: number | string;
99
207
  fondo?: string;
100
208
  niebla?: [number, number];
101
209
  sol?: V3;
102
210
  foco?: V3;
211
+ etiquetas?: Etiqueta3D[];
212
+ /** Encima del 3D, en coordenadas de pantalla (texto, tarjetas). */
213
+ encima?: React.ReactNode;
103
214
  children: React.ReactNode;
104
- }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], children}) => {
215
+ }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', hora, fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], etiquetas, encima, children}) => {
105
216
  const estado = camara3D(tomas, escena, tEscena, inicios);
106
- const color = fondo ?? (luz === 'dia' ? '#BFE3FF' : '#120A06');
217
+ const cielo = cieloDeHora(hora ?? 12);
218
+ const color = fondo ?? (luz === 'dia' ? cielo.fondo : '#120A06');
219
+ const posSol: V3 = [sol[0], sol[1] * cielo.altura + 1.5, sol[2]];
107
220
  return (
108
- <div style={{width: 1080, height: 1920}}>
221
+ <div style={{position: 'relative', width: 1080, height: 1920}}>
109
222
  <ThreeCanvas width={1080} height={1920} shadows gl={{antialias: true}} camera={{fov: estado.fov, position: estado.pos, near: 0.1, far: 400}}>
110
223
  <color attach="background" args={[color]} />
111
224
  {niebla ? <fog attach="fog" args={[color, niebla[0], niebla[1]]} /> : null}
112
225
  <Camara estado={estado} />
226
+ <Recorte />
113
227
  {luz === 'dia' ? (
114
228
  <>
115
- <Entorno fuerza={0.55} />
116
- <hemisphereLight args={['#ffffff', '#b9a98a', 1.05]} />
117
- <directionalLight position={sol} intensity={2.3} castShadow shadow-mapSize={[2048, 2048]} shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} shadow-camera-far={90} shadow-bias={-0.0006} />
229
+ <Entorno fuerza={cielo.entorno} />
230
+ <hemisphereLight args={[cielo.arriba, cielo.abajo, cielo.fuerzaHemi]} />
231
+ <directionalLight position={posSol} color={cielo.sol} intensity={cielo.fuerzaSol} castShadow shadow-mapSize={[2048, 2048]} shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} shadow-camera-far={90} shadow-bias={-0.0006} />
118
232
  </>
119
233
  ) : (
120
234
  <>
@@ -130,6 +244,8 @@ export const Lienzo3D: React.FC<{
130
244
  )}
131
245
  {children}
132
246
  </ThreeCanvas>
247
+ {etiquetas?.length ? <EtiquetasPantalla etiquetas={etiquetas} estado={estado} escena={escena} tEscena={tEscena} /> : null}
248
+ {encima}
133
249
  </div>
134
250
  );
135
251
  };