morfeo 0.1.3 → 0.2.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.
@@ -255,7 +255,7 @@ def preparar(job):
255
255
  subs = [dict(a=round(c[0][1], 3), b=round(c[-1][2], 3), w=[[x[0].upper(), round(x[1], 3), round(x[2], 3)] for x in c])
256
256
  for ws in WORDS for c in trozos(ws)]
257
257
  datos = dict(id=vid, titulo=v.get("title", vid), indice=job.get("indice", 0), duracion=round(sum(durs), 3),
258
- estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [],
258
+ estilo=v.get("estilo", "datos-curiosos"), tema=v.get("tema") or {}, hud=v.get("hud") or [], mundo=v.get("mundo") or [], serie=v.get("serie"),
259
259
  audio=f"v/{vid}/mix.wav", whisper=reales, escenas=escenas, subtitulos=subs)
260
260
  json.dump(datos, open(os.path.join(outdir, "datos.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
261
261
  print(f" tiempos por palabra: {'Whisper' if reales else 'reparto por frase'} ({reales}/{len(TIM)} escenas)", flush=True)
@@ -54,6 +54,19 @@ export const backOut = (x: number, s = 1.9) => {
54
54
  const c = clamp(x) - 1;
55
55
  return c * c * ((s + 1) * c + s) + 1;
56
56
  };
57
+ /** Resorte amortiguado normalizado a 0..1: llega rápido, se pasa un poco y se asienta. `rebote` 0 = sin pasarse. */
58
+ export const resorte = (x: number, rebote = 0.35) => {
59
+ const c = clamp(x);
60
+ if (c >= 1) return 1;
61
+ return 1 - Math.exp(-7.5 * c) * Math.cos(c * Math.PI * (1.2 + rebote * 3.2));
62
+ };
63
+ /** Frenado fuerte: casi todo el recorrido en el primer tercio (movimiento "seco", editorial). */
64
+ export const easeOutExpo = (x: number) => {
65
+ const c = clamp(x);
66
+ return c >= 1 ? 1 : 1 - 2 ** (-10 * c);
67
+ };
68
+ /** Ruido suave determinista en -1..1 (suma de senos con fases distintas): temblores y derivas de cámara. */
69
+ export const ruido = (t: number, semilla = 0) => (Math.sin(t * 1.7 + semilla) + Math.sin(t * 2.9 + semilla * 2.3) * 0.6 + Math.sin(t * 5.3 + semilla * 4.1) * 0.3) / 1.9;
57
70
  export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
58
71
  export const mezclaColor = (c1: string, c2: string, t: number) => {
59
72
  const p = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
@@ -0,0 +1,220 @@
1
+ // Kit ilustrado 2D: piezas SVG para armar el mundo de un video (bosque, noche, campo) en un componente propio.
2
+ // Todas van DENTRO de un <svg> y dependen solo de sus props (y de `t` en segundos): nada se anima solo.
3
+ //
4
+ // import {Filtros, Cielo, Estrellas, Luna, Montanas, Pinos, Suelo, Pasto, Luciernagas, Paralaje, Texturizado} from 'morfeo/kit';
5
+ //
6
+ // Un mundo se dibuja más grande que el cuadro (p. ej. 2160×1920) y la cámara lo recorre con la `toma` de cada escena.
7
+ // `Paralaje` mueve cada plano según la cámara: lo lejano casi no se mueve y lo cercano se mueve de más.
8
+ import React from 'react';
9
+
10
+ export const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
11
+ export const suave = (x: number) => {
12
+ const c = clamp01(x);
13
+ return c * c * (3 - 2 * c);
14
+ };
15
+ /** Número pseudoaleatorio estable en [0, 1) a partir de un entero. */
16
+ export const azarN = (n: number) => {
17
+ const x = Math.sin(n * 127.1 + 11.7) * 43758.5453;
18
+ return x - Math.floor(x);
19
+ };
20
+
21
+ export type Camara = {x: number; y: number; zoom: number; giro: number};
22
+
23
+ /**
24
+ * Filtros compartidos (ponlo una vez dentro del <svg>):
25
+ * url(#kit-grano) motas oscuras recortadas a la figura (papel, pelaje, tierra)
26
+ * url(#kit-brillo) resplandor suave alrededor (luna, luciérnagas, luces)
27
+ * url(#kit-lejos) desenfoque leve para lo que queda fuera de foco
28
+ */
29
+ export const Filtros: React.FC<{grano?: number}> = ({grano = 0.55}) => (
30
+ <defs>
31
+ <filter id="kit-grano" x="-5%" y="-5%" width="110%" height="110%">
32
+ <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="7" result="ruido" />
33
+ <feColorMatrix in="ruido" type="matrix" values={`0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${grano} 0 0 0 ${-grano * 0.32}`} result="motas" />
34
+ <feComposite in="motas" in2="SourceAlpha" operator="in" result="recorte" />
35
+ <feMerge>
36
+ <feMergeNode in="SourceGraphic" />
37
+ <feMergeNode in="recorte" />
38
+ </feMerge>
39
+ </filter>
40
+ <filter id="kit-brillo" x="-60%" y="-60%" width="220%" height="220%">
41
+ <feGaussianBlur stdDeviation="14" result="b" />
42
+ <feMerge>
43
+ <feMergeNode in="b" />
44
+ <feMergeNode in="SourceGraphic" />
45
+ </feMerge>
46
+ </filter>
47
+ <filter id="kit-lejos" x="-10%" y="-10%" width="120%" height="120%">
48
+ <feGaussianBlur stdDeviation="5" />
49
+ </filter>
50
+ </defs>
51
+ );
52
+
53
+ /** Grupo con grano recortado a sus figuras. Úsalo en personajes y objetos, no en fondos enormes (cuesta render). */
54
+ export const Texturizado: React.FC<{children: React.ReactNode}> = ({children}) => <g filter="url(#kit-grano)">{children}</g>;
55
+
56
+ /**
57
+ * Plano con paralaje. `profundidad`: 0 = pegado al mundo, 1 = fijo en pantalla (infinitamente lejos),
58
+ * negativo = primer plano (se mueve más que el mundo). Sin `camara` no hace nada.
59
+ */
60
+ export const Paralaje: React.FC<{camara?: Camara; profundidad: number; children: React.ReactNode}> = ({camara, profundidad, children}) => {
61
+ if (!camara || !profundidad) return <g>{children}</g>;
62
+ return <g transform={`translate(${(camara.x - 540) * profundidad} ${(camara.y - 960) * profundidad * 0.6})`}>{children}</g>;
63
+ };
64
+
65
+ /** Cielo en degradado vertical. Dale margen de sobra (x0, x1, y0) para que la cámara nunca vea el borde. */
66
+ export const Cielo: React.FC<{id?: string; colores: string[]; x0?: number; x1?: number; y0?: number; y1?: number}> = ({id = 'kit-cielo', colores, x0 = -1200, x1 = 3400, y0 = -900, y1 = 1920}) => (
67
+ <>
68
+ <defs>
69
+ <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
70
+ {colores.map((c, i) => (
71
+ <stop key={i} offset={i / Math.max(1, colores.length - 1)} stopColor={c} />
72
+ ))}
73
+ </linearGradient>
74
+ </defs>
75
+ <rect x={x0} y={y0} width={x1 - x0} height={y1 - y0} fill={`url(#${id})`} />
76
+ </>
77
+ );
78
+
79
+ /** Estrellas que titilan. */
80
+ export const Estrellas: React.FC<{t: number; n?: number; x0?: number; x1?: number; y0?: number; y1?: number; semilla?: number}> = ({t, n = 90, x0 = -400, x1 = 2600, y0 = -300, y1 = 1100, semilla = 0}) => (
81
+ <g>
82
+ {Array.from({length: n}, (_, i) => {
83
+ const s = semilla + i;
84
+ const o = 0.3 + 0.7 * Math.abs(Math.sin(t * (0.8 + azarN(s + 300) * 1.8) + i));
85
+ return <circle key={i} cx={x0 + azarN(s) * (x1 - x0)} cy={y0 + azarN(s + 100) * (y1 - y0)} r={1.2 + azarN(s + 200) * 2.6} fill="#fff" opacity={o} />;
86
+ })}
87
+ </g>
88
+ );
89
+
90
+ /** Luna con halo y cráteres; con `cara` tiene ojos y boca, y con `canta` (0..1) abre la boca al ritmo. */
91
+ export const Luna: React.FC<{x: number; y: number; r?: number; t?: number; cara?: boolean; canta?: number}> = ({x, y, r = 110, t = 0, cara, canta = 0}) => {
92
+ const u = r / 110;
93
+ const boca = canta > 0 ? (6 + Math.abs(Math.sin(t * 9)) * 14) * canta : 0;
94
+ return (
95
+ <g transform={`translate(${x} ${y}) scale(${u})`}>
96
+ <defs>
97
+ <radialGradient id="kit-halo">
98
+ <stop offset="0" stopColor="rgba(255,240,200,0.6)" />
99
+ <stop offset="1" stopColor="rgba(255,240,200,0)" />
100
+ </radialGradient>
101
+ </defs>
102
+ <circle r={300} fill="url(#kit-halo)" />
103
+ <g filter="url(#kit-grano)">
104
+ <circle r={110} fill="#FFF4D6" />
105
+ <circle cx={-34} cy={-26} r={16} fill="rgba(210,190,150,0.45)" />
106
+ <circle cx={38} cy={30} r={22} fill="rgba(210,190,150,0.4)" />
107
+ <circle cx={18} cy={-48} r={9} fill="rgba(210,190,150,0.4)" />
108
+ </g>
109
+ {cara ? (
110
+ <g>
111
+ {canta > 0.5 ? (
112
+ <>
113
+ <path d="M-42 -8 Q-30 -18 -18 -8" stroke="#6B5236" strokeWidth={6} fill="none" strokeLinecap="round" />
114
+ <path d="M18 -8 Q30 -18 42 -8" stroke="#6B5236" strokeWidth={6} fill="none" strokeLinecap="round" />
115
+ </>
116
+ ) : (
117
+ <>
118
+ <circle cx={-30} cy={-10} r={8} fill="#6B5236" />
119
+ <circle cx={30} cy={-10} r={8} fill="#6B5236" />
120
+ </>
121
+ )}
122
+ <ellipse cx={0} cy={34} rx={canta > 0.5 ? 14 : 16} ry={canta > 0.5 ? boca : 4} fill={canta > 0.5 ? '#7A3B3B' : '#6B5236'} />
123
+ <circle cx={-58} cy={18} r={12} fill="rgba(255,150,150,0.45)" />
124
+ <circle cx={58} cy={18} r={12} fill="rgba(255,150,150,0.45)" />
125
+ </g>
126
+ ) : null}
127
+ </g>
128
+ );
129
+ };
130
+
131
+ /** Cordillera: una silueta de picos irregulares entre x0 y x1, con la base en `base`. */
132
+ export const Montanas: React.FC<{base: number; alto: number; x0?: number; x1?: number; picos?: number; semilla?: number; color: string}> = ({base, alto, x0 = -600, x1 = 2800, picos = 9, semilla = 0, color}) => {
133
+ let d = `M${x0} ${base + 400} L${x0} ${base}`;
134
+ for (let i = 0; i <= picos; i++) {
135
+ const x = x0 + ((x1 - x0) * i) / picos;
136
+ const h = alto * (0.45 + azarN(semilla + i * 5) * 0.55);
137
+ d += ` L${x - (x1 - x0) / picos / 2} ${base - h} L${x} ${base - h * 0.35}`;
138
+ }
139
+ return <path d={`${d} L${x1} ${base + 400} Z`} fill={color} />;
140
+ };
141
+
142
+ /** Fila de pinos como un solo trazado (liviano). */
143
+ export const Pinos: React.FC<{base: number; alto: number; ancho: number; n: number; x0?: number; x1?: number; semilla?: number; color: string}> = ({base, alto, ancho, n, x0 = -500, x1 = 2700, semilla = 0, color}) => {
144
+ let d = '';
145
+ for (let i = 0; i < n; i++) {
146
+ const cx = x0 + ((x1 - x0) * i) / Math.max(1, n - 1) + (azarN(semilla + i) - 0.5) * ancho * 0.6;
147
+ const h = alto * (0.75 + azarN(semilla + i * 3) * 0.5);
148
+ const w = ancho * (0.8 + azarN(semilla + i * 7) * 0.4);
149
+ for (let p = 0; p < 3; p++) {
150
+ const top = base - h + p * h * 0.26;
151
+ const bot = base - h * 0.32 + p * h * 0.22;
152
+ const ww = w * (0.55 + p * 0.25);
153
+ d += `M${cx} ${top} L${cx + ww / 2} ${bot} L${cx - ww / 2} ${bot} Z `;
154
+ }
155
+ d += `M${cx - w * 0.06} ${base - h * 0.12} h${w * 0.12} v${h * 0.16} h${-w * 0.12} Z `;
156
+ }
157
+ return <path d={d} fill={color} />;
158
+ };
159
+
160
+ /** Suelo ondulado: una loma suave de x0 a x1 que baja hasta `fondo`. `y(x)` da la altura del suelo en un punto. */
161
+ export const alturaSuelo = (x: number, y: number, onda = 28) => y + Math.sin(x / 310) * onda + Math.sin(x / 127 + 1.3) * onda * 0.3;
162
+ export const Suelo: React.FC<{y: number; x0?: number; x1?: number; fondo?: number; onda?: number; id?: string; colores: [string, string]}> = ({y, x0 = -800, x1 = 3000, fondo = 2600, onda = 28, id = 'kit-suelo', colores}) => {
163
+ let d = `M${x0} ${fondo}`;
164
+ for (let x = x0; x <= x1; x += 40) d += ` L${x} ${alturaSuelo(x, y, onda)}`;
165
+ return (
166
+ <>
167
+ <defs>
168
+ <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
169
+ <stop offset="0" stopColor={colores[0]} />
170
+ <stop offset="0.55" stopColor={colores[1]} />
171
+ </linearGradient>
172
+ </defs>
173
+ <path d={`${d} L${x1} ${fondo} Z`} fill={`url(#${id})`} />
174
+ </>
175
+ );
176
+ };
177
+
178
+ /** Briznas de pasto sobre el suelo, que se mecen con el viento. */
179
+ export const Pasto: React.FC<{y: number; t: number; n?: number; x0?: number; x1?: number; alto?: number; onda?: number; semilla?: number; color: string; grosor?: number}> = ({y, t, n = 160, x0 = -400, x1 = 2600, alto = 46, onda = 28, semilla = 0, color, grosor = 5}) => {
180
+ let d = '';
181
+ for (let i = 0; i < n; i++) {
182
+ const x = x0 + ((x1 - x0) * (i + azarN(semilla + i) * 0.9)) / n;
183
+ const h = alto * (0.5 + azarN(semilla + i * 3) * 0.8);
184
+ const viento = Math.sin(t * 1.6 + x / 140) * h * 0.22 + (azarN(semilla + i * 9) - 0.5) * h * 0.5;
185
+ const by = alturaSuelo(x, y, onda) + 6;
186
+ d += `M${x} ${by} Q${x + viento * 0.3} ${by - h * 0.6} ${x + viento} ${by - h} `;
187
+ }
188
+ return <path d={d} stroke={color} strokeWidth={grosor} strokeLinecap="round" fill="none" />;
189
+ };
190
+
191
+ /** Luciérnagas: puntos cálidos que flotan y parpadean. */
192
+ export const Luciernagas: React.FC<{t: number; n?: number; x0?: number; x1?: number; y0?: number; y1?: number; semilla?: number; color?: string}> = ({t, n = 26, x0 = 0, x1 = 2160, y0 = 1000, y1 = 1700, semilla = 0, color = '#FFE98A'}) => (
193
+ <g filter="url(#kit-brillo)">
194
+ {Array.from({length: n}, (_, i) => {
195
+ const s = semilla + i;
196
+ const x = x0 + azarN(s) * (x1 - x0) + Math.sin(t * (0.4 + azarN(s + 50) * 0.5) + i) * 40;
197
+ const y = y0 + azarN(s + 20) * (y1 - y0) + Math.cos(t * (0.3 + azarN(s + 70) * 0.5) + i * 2) * 30;
198
+ const o = 0.25 + 0.75 * Math.abs(Math.sin(t * (0.9 + azarN(s + 90)) + i * 1.7));
199
+ return <circle key={i} cx={x} cy={y} r={3 + azarN(s + 40) * 3} fill={color} opacity={o} />;
200
+ })}
201
+ </g>
202
+ );
203
+
204
+ /** Sombra de contacto: una elipse oscura y suave bajo los pies de algo. */
205
+ export const Sombra: React.FC<{x: number; y: number; ancho: number; fuerza?: number}> = ({x, y, ancho, fuerza = 0.35}) => (
206
+ <ellipse cx={x} cy={y} rx={ancho / 2} ry={ancho * 0.09} fill={`rgba(0,0,0,${fuerza})`} filter="url(#kit-lejos)" />
207
+ );
208
+
209
+ /** Cono de luz (lámpara, luna, linterna) desde (x, y) hacia abajo, girado `angulo` grados. */
210
+ export const ConoLuz: React.FC<{id?: string; x: number; y: number; ancho: number; largo: number; angulo?: number; color?: string; fuerza?: number}> = ({id = 'kit-cono', x, y, ancho, largo, angulo = 0, color = '#FFE8B0', fuerza = 0.35}) => (
211
+ <g transform={`rotate(${angulo} ${x} ${y})`} style={{mixBlendMode: 'screen'}}>
212
+ <defs>
213
+ <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
214
+ <stop offset="0" stopColor={color} stopOpacity={fuerza} />
215
+ <stop offset="1" stopColor={color} stopOpacity="0" />
216
+ </linearGradient>
217
+ </defs>
218
+ <polygon points={`${x - 30},${y} ${x + 30},${y} ${x + ancho / 2},${y + largo} ${x - ancho / 2},${y + largo}`} fill={`url(#${id})`} filter="url(#kit-lejos)" />
219
+ </g>
220
+ );
@@ -0,0 +1,6 @@
1
+ // Kits de Morfeo: piezas para armar las escenas a medida de un video dentro de tus `componentes`.
2
+ // import {Cielo, Pinos, Paralaje} from 'morfeo/kit'; (2D ilustrado, dentro de un <svg>)
3
+ // import {Lienzo3D, Terreno, Tubo, Mesa, Pila} from 'morfeo/kit'; (3D: maqueta de día o mesa con luz cálida)
4
+ export * from './ilustrado';
5
+ export * from './tres';
6
+ export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';
@@ -0,0 +1,328 @@
1
+ // Kit 3D: piezas de Three.js para armar el mundo de un video (maqueta en corte, mesa con foco de luz) en un componente propio.
2
+ //
3
+ // import {Lienzo3D, Terreno, Tubo, Arbol, Casa, Mesa, Pila} from 'morfeo/kit';
4
+ //
5
+ // Todo el mundo va en UN solo <Lienzo3D> (Chrome aguanta ~16 contextos WebGL: no pongas un lienzo por objeto).
6
+ // La cámara se mueve por `tomas`: una por escena; viaja desde donde terminó la anterior y luego deriva despacio.
7
+ // Nada usa useFrame: todo depende de las props (`t`, `tEscena`), como pide Remotion.
8
+ import {useThree} from '@react-three/fiber';
9
+ import {ThreeCanvas} from '@remotion/three';
10
+ import React, {useEffect, useLayoutEffect, useMemo, useRef} from 'react';
11
+ import * as THREE from 'three';
12
+ import {RoomEnvironment} from 'three/examples/jsm/environments/RoomEnvironment.js';
13
+ import {azarN} from './ilustrado';
14
+
15
+ export {Objeto3D} from '../motor/objetos3d';
16
+
17
+ type V3 = [number, number, number];
18
+ const suave = (x: number) => {
19
+ const c = Math.max(0, Math.min(1, x));
20
+ return c * c * (3 - 2 * c);
21
+ };
22
+ const mezcla = (a: V3, b: V3, k: number): V3 => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
23
+
24
+ /**
25
+ * Toma de cámara de una escena: dónde está (`pos`) y a dónde mira (`mirar`). Con `a` se mueve hasta ahí durante la
26
+ * escena; sin `a` se acerca un poco sola (`deriva`, 0.06 = 6 % del camino hacia lo que mira). `corte` salta sin viaje.
27
+ */
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};
30
+
31
+ /** Cámara del cuadro actual. `tomas[i]` es la de la escena i+1; si falta, se conserva la anterior. */
32
+ export function camara3D(tomas: (Toma3D | undefined | null)[], escena: number, tEscena: number, inicios?: number[]): Estado {
33
+ const de = (i: number): Toma3D => {
34
+ for (let j = Math.min(i, tomas.length - 1); j >= 0; j--) if (tomas[j]) return tomas[j]!;
35
+ return {pos: [6, 4, 8]};
36
+ };
37
+ const inicio = (i: number): Estado => ({pos: de(i).pos, mirar: de(i).mirar ?? [0, 0, 0], fov: de(i).fov ?? 38});
38
+ const final = (i: number): Estado => {
39
+ const tm = tomas[i] ? de(i) : ({...de(i), a: undefined} as Toma3D);
40
+ const a = inicio(i);
41
+ if (tm.a) return {pos: tm.a.pos ?? a.pos, mirar: tm.a.mirar ?? a.mirar, fov: tm.a.fov ?? a.fov};
42
+ return {...a, pos: mezcla(a.pos, a.mirar, tm.deriva ?? 0.06)};
43
+ };
44
+ const i = Math.max(0, escena - 1);
45
+ const tm = de(i);
46
+ const dur = inicios && inicios[i + 1] != null ? inicios[i + 1] - inicios[i] : 5;
47
+ const viaje = tm.corte || i === 0 || !tomas[i] ? 0 : tm.viaje ?? Math.min(1.2, dur * 0.5);
48
+ const a = inicio(i);
49
+ const b = final(i);
50
+ const kd = Math.max(0, Math.min(1, (tEscena - viaje * 0.6) / Math.max(0.01, dur - viaje * 0.6)));
51
+ const kq = tm.a ? suave(kd) : kd;
52
+ const actual: Estado = {pos: mezcla(a.pos, b.pos, kq), mirar: mezcla(a.mirar, b.mirar, kq), fov: a.fov + (b.fov - a.fov) * kq};
53
+ if (viaje <= 0 || tEscena >= viaje) return actual;
54
+ const o = final(i - 1);
55
+ const kv = suave(tEscena / viaje);
56
+ return {pos: mezcla(o.pos, actual.pos, kv), mirar: mezcla(o.mirar, actual.mirar, kv), fov: o.fov + (actual.fov - o.fov) * kv};
57
+ }
58
+
59
+ const Camara: React.FC<{estado: Estado}> = ({estado}) => {
60
+ const {camera} = useThree();
61
+ useLayoutEffect(() => {
62
+ camera.position.set(...estado.pos);
63
+ camera.lookAt(...estado.mirar);
64
+ const p = camera as THREE.PerspectiveCamera;
65
+ if (p.isPerspectiveCamera) {
66
+ p.fov = estado.fov;
67
+ p.updateProjectionMatrix();
68
+ }
69
+ });
70
+ return null;
71
+ };
72
+
73
+ const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
74
+ const {gl, scene} = useThree();
75
+ useEffect(() => {
76
+ const pmrem = new THREE.PMREMGenerator(gl);
77
+ const tex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
78
+ scene.environment = tex;
79
+ scene.environmentIntensity = fuerza;
80
+ return () => {
81
+ tex.dispose();
82
+ pmrem.dispose();
83
+ };
84
+ }, [gl, scene, fuerza]);
85
+ return null;
86
+ };
87
+
88
+ /**
89
+ * El lienzo del mundo 3D (1080×1920). `luz`:
90
+ * 'dia' cielo claro, sol con sombras suaves (maquetas, cortes de terreno, low-poly)
91
+ * 'calida' cuarto oscuro con un foco cálido desde arriba y su cono visible (mesa, dinero, objetos)
92
+ */
93
+ export const Lienzo3D: React.FC<{
94
+ tomas: (Toma3D | undefined | null)[];
95
+ escena?: number;
96
+ tEscena?: number;
97
+ inicios?: number[];
98
+ luz?: 'dia' | 'calida';
99
+ fondo?: string;
100
+ niebla?: [number, number];
101
+ sol?: V3;
102
+ foco?: V3;
103
+ children: React.ReactNode;
104
+ }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], children}) => {
105
+ const estado = camara3D(tomas, escena, tEscena, inicios);
106
+ const color = fondo ?? (luz === 'dia' ? '#BFE3FF' : '#120A06');
107
+ return (
108
+ <div style={{width: 1080, height: 1920}}>
109
+ <ThreeCanvas width={1080} height={1920} shadows gl={{antialias: true}} camera={{fov: estado.fov, position: estado.pos, near: 0.1, far: 400}}>
110
+ <color attach="background" args={[color]} />
111
+ {niebla ? <fog attach="fog" args={[color, niebla[0], niebla[1]]} /> : null}
112
+ <Camara estado={estado} />
113
+ {luz === 'dia' ? (
114
+ <>
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} />
118
+ </>
119
+ ) : (
120
+ <>
121
+ <Entorno fuerza={0.22} />
122
+ <ambientLight color="#6A4630" intensity={0.55} />
123
+ <spotLight position={foco} angle={0.52} penumbra={0.85} decay={0} intensity={3.4} color="#FFDDAA" castShadow shadow-mapSize={[2048, 2048]} shadow-bias={-0.0004} />
124
+ {/* cono de luz visible */}
125
+ <mesh position={[foco[0], foco[1] / 2, foco[2]]}>
126
+ <coneGeometry args={[foco[1] * 0.56, foco[1], 48, 1, true]} />
127
+ <meshBasicMaterial color="#FFD9A0" transparent opacity={0.055} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
128
+ </mesh>
129
+ </>
130
+ )}
131
+ {children}
132
+ </ThreeCanvas>
133
+ </div>
134
+ );
135
+ };
136
+
137
+ type Pieza = {pos?: V3; rot?: V3; escala?: number};
138
+ const RAD = Math.PI / 180;
139
+ 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});
140
+
141
+ /** Bloque de color mate: la pieza base para cajas, muros, banquetas, cualquier cosa. */
142
+ export const Bloque: React.FC<Pieza & {tam: V3; color: string; rugosidad?: number; metal?: number; opacidad?: number; radio?: number}> = ({tam, color, rugosidad = 0.8, metal = 0, opacidad, ...p}) => (
143
+ <mesh {...grupo(p)} castShadow receiveShadow>
144
+ <boxGeometry args={tam} />
145
+ <meshStandardMaterial color={color} roughness={rugosidad} metalness={metal} transparent={opacidad != null} opacity={opacidad ?? 1} />
146
+ </mesh>
147
+ );
148
+
149
+ /**
150
+ * Terreno en corte: capas apiladas hacia abajo desde y = 0 (pasto, tierra, roca...). La cara de enfrente (z = fondo / 2)
151
+ * queda a la vista para mostrar lo que hay bajo tierra. capas: [[color, grosor], ...]
152
+ */
153
+ export const Terreno: React.FC<Pieza & {ancho?: number; fondo?: number; capas?: [string, number][]}> = ({ancho = 22, fondo = 9, capas = [['#6CC04A', 0.35], ['#8A5A3B', 3.2], ['#77726E', 3]], ...p}) => {
154
+ let y = 0;
155
+ return (
156
+ <group {...grupo(p)}>
157
+ {capas.map(([color, grosor], i) => {
158
+ const centro = y - grosor / 2;
159
+ y -= grosor;
160
+ return (
161
+ <mesh key={i} position={[0, centro, 0]} receiveShadow castShadow={i === 0}>
162
+ <boxGeometry args={[ancho, grosor, fondo]} />
163
+ <meshStandardMaterial color={color} roughness={0.95} />
164
+ </mesh>
165
+ );
166
+ })}
167
+ </group>
168
+ );
169
+ };
170
+
171
+ /** Piedritas incrustadas en la cara del corte, para que la tierra no se vea plana. */
172
+ export const Piedras: React.FC<{n?: number; ancho?: number; y0?: number; y1?: number; z: number; color?: string; semilla?: number}> = ({n = 40, ancho = 22, y0 = -0.6, y1 = -6, z, color = '#6B4530', semilla = 0}) => (
173
+ <group>
174
+ {Array.from({length: n}, (_, i) => (
175
+ <mesh key={i} position={[(azarN(semilla + i) - 0.5) * ancho * 0.96, y0 + (y1 - y0) * azarN(semilla + i * 3 + 1), z]} rotation={[0, 0, azarN(semilla + i * 7) * 3]} scale={[1, 0.7, 0.25]}>
176
+ <icosahedronGeometry args={[0.08 + azarN(semilla + i * 5) * 0.14, 0]} />
177
+ <meshStandardMaterial color={color} roughness={1} flatShading />
178
+ </mesh>
179
+ ))}
180
+ </group>
181
+ );
182
+
183
+ /** Árbol low-poly: tronco y dos copas facetadas. */
184
+ export const Arbol: React.FC<Pieza & {semilla?: number; color?: string}> = ({semilla = 0, color = '#3F9B3A', ...p}) => {
185
+ const k = 0.85 + azarN(semilla) * 0.4;
186
+ return (
187
+ <group {...grupo(p)}>
188
+ <group scale={k} rotation={[0, azarN(semilla + 1) * 6, 0]}>
189
+ <mesh position={[0, 0.55, 0]} castShadow>
190
+ <cylinderGeometry args={[0.11, 0.17, 1.1, 7]} />
191
+ <meshStandardMaterial color="#7A4E2D" roughness={0.9} flatShading />
192
+ </mesh>
193
+ <mesh position={[0, 1.45, 0]} castShadow>
194
+ <icosahedronGeometry args={[0.72, 0]} />
195
+ <meshStandardMaterial color={color} roughness={0.85} flatShading />
196
+ </mesh>
197
+ <mesh position={[0.28, 1.95, 0.1]} castShadow>
198
+ <icosahedronGeometry args={[0.46, 0]} />
199
+ <meshStandardMaterial color={color} roughness={0.85} flatShading />
200
+ </mesh>
201
+ </group>
202
+ </group>
203
+ );
204
+ };
205
+
206
+ /** Arbusto: una bola facetada aplastada. */
207
+ export const Arbusto: React.FC<Pieza & {color?: string}> = ({color = '#4FAE45', ...p}) => (
208
+ <group {...grupo(p)}>
209
+ <mesh position={[0, 0.3, 0]} scale={[1, 0.7, 1]} castShadow>
210
+ <icosahedronGeometry args={[0.5, 1]} />
211
+ <meshStandardMaterial color={color} roughness={0.9} flatShading />
212
+ </mesh>
213
+ </group>
214
+ );
215
+
216
+ /** Casa sencilla: cuerpo, techo a cuatro aguas, puerta y ventana. Mide ~2.4 de ancho; la base queda en y = 0. */
217
+ export const Casa: React.FC<Pieza & {color?: string; techo?: string}> = ({color = '#F2E6D0', techo = '#D9533B', ...p}) => (
218
+ <group {...grupo(p)}>
219
+ <mesh position={[0, 0.8, 0]} castShadow receiveShadow>
220
+ <boxGeometry args={[2.4, 1.6, 2]} />
221
+ <meshStandardMaterial color={color} roughness={0.85} />
222
+ </mesh>
223
+ <mesh position={[0, 2.05, 0]} rotation={[0, Math.PI / 4, 0]} castShadow>
224
+ <coneGeometry args={[1.95, 0.95, 4]} />
225
+ <meshStandardMaterial color={techo} roughness={0.8} flatShading />
226
+ </mesh>
227
+ <mesh position={[-0.5, 0.5, 1.01]}>
228
+ <boxGeometry args={[0.5, 1, 0.04]} />
229
+ <meshStandardMaterial color="#7A4E2D" roughness={0.8} />
230
+ </mesh>
231
+ <mesh position={[0.55, 0.95, 1.01]}>
232
+ <boxGeometry args={[0.6, 0.5, 0.04]} />
233
+ <meshStandardMaterial color="#9AD4F5" roughness={0.3} />
234
+ </mesh>
235
+ </group>
236
+ );
237
+
238
+ /**
239
+ * Tubo que pasa por `puntos`. `agua` (0..1) lo llena desde el primer punto: anímalo con el tiempo para que el agua avance.
240
+ * El tubo es translúcido para que se vea lo que lleva dentro.
241
+ */
242
+ export const Tubo: React.FC<{puntos: V3[]; radio?: number; color?: string; agua?: number; colorAgua?: string; opacidad?: number}> = ({puntos, radio = 0.22, color = '#E3E5EA', agua = 0, colorAgua = '#1E8BFF', opacidad = 0.34}) => {
243
+ const llave = JSON.stringify(puntos);
244
+ const SEG = 220;
245
+ const RADIAL = 14;
246
+ const [exterior, interior] = useMemo(() => {
247
+ const curva = new THREE.CatmullRomCurve3(
248
+ puntos.map((p) => new THREE.Vector3(...p)),
249
+ false,
250
+ 'catmullrom',
251
+ 0.06,
252
+ );
253
+ return [new THREE.TubeGeometry(curva, SEG, radio, RADIAL, false), new THREE.TubeGeometry(curva, SEG, radio * 0.8, RADIAL, false)];
254
+ }, [llave, radio]);
255
+ const k = Math.max(0, Math.min(1, agua));
256
+ useLayoutEffect(() => {
257
+ interior.setDrawRange(0, Math.floor(k * SEG) * RADIAL * 6);
258
+ });
259
+ return (
260
+ <group>
261
+ {k > 0 ? (
262
+ <mesh geometry={interior}>
263
+ <meshStandardMaterial color={colorAgua} roughness={0.25} emissive={colorAgua} emissiveIntensity={0.55} />
264
+ </mesh>
265
+ ) : null}
266
+ <mesh geometry={exterior} castShadow>
267
+ <meshStandardMaterial color={color} roughness={0.35} transparent opacity={opacidad} depthWrite={false} />
268
+ </mesh>
269
+ </group>
270
+ );
271
+ };
272
+
273
+ /** Superficie de agua (río, lago, charco). */
274
+ export const Agua: React.FC<Pieza & {tam: [number, number]; color?: string}> = ({tam, color = '#3AA7FF', ...p}) => (
275
+ <mesh {...grupo(p)} receiveShadow>
276
+ <boxGeometry args={[tam[0], 0.06, tam[1]]} />
277
+ <meshStandardMaterial color={color} roughness={0.15} metalness={0.1} transparent opacity={0.9} />
278
+ </mesh>
279
+ );
280
+
281
+ /** Mesa de madera redonda y grande: el piso de las escenas con foco de luz. La superficie queda en y = 0. */
282
+ export const Mesa: React.FC<{radio?: number; color?: string}> = ({radio = 14, color = '#6B4226'}) => (
283
+ <mesh position={[0, -0.25, 0]} receiveShadow>
284
+ <cylinderGeometry args={[radio, radio, 0.5, 64]} />
285
+ <meshStandardMaterial color={color} roughness={0.62} metalness={0.05} />
286
+ </mesh>
287
+ );
288
+
289
+ /**
290
+ * Pilas de monedas o billetes. `n` = cuántas piezas se ven (anímalo para que la pila crezca); se reparten en
291
+ * columnas de `porColumna`, acomodadas en una rejilla alrededor de `pos`.
292
+ */
293
+ export const Pila: React.FC<{forma?: 'moneda' | 'billete'; n: number; max?: number; porColumna?: number; pos?: V3; color?: string; separacion?: number; semilla?: number}> = ({forma = 'moneda', n, max = 400, porColumna = 24, pos = [0, 0, 0], color, separacion, semilla = 0}) => {
294
+ const ref = useRef<THREE.InstancedMesh>(null);
295
+ const moneda = forma === 'moneda';
296
+ const alto = moneda ? 0.1 : 0.04;
297
+ const paso = separacion ?? (moneda ? 1.15 : 1.9);
298
+ useLayoutEffect(() => {
299
+ const m = ref.current;
300
+ if (!m) return;
301
+ const cols = Math.ceil(max / porColumna);
302
+ const lado = Math.ceil(Math.sqrt(cols));
303
+ const o = new THREE.Object3D();
304
+ for (let i = 0; i < max; i++) {
305
+ const c = Math.floor(i / porColumna);
306
+ const nivel = i % porColumna;
307
+ // las columnas se llenan del centro hacia afuera
308
+ const cx = (c % lado) - (lado - 1) / 2;
309
+ const cz = Math.floor(c / lado) - (lado - 1) / 2;
310
+ o.position.set(cx * paso + (azarN(semilla + i) - 0.5) * 0.07, alto / 2 + nivel * (alto + 0.004), cz * (moneda ? paso : paso * 0.55) + (azarN(semilla + i * 3) - 0.5) * 0.07);
311
+ o.rotation.set(0, azarN(semilla + i * 7) * (moneda ? 6.28 : 0.16) - (moneda ? 0 : 0.08), 0);
312
+ o.updateMatrix();
313
+ m.setMatrixAt(i, o.matrix);
314
+ }
315
+ m.instanceMatrix.needsUpdate = true;
316
+ }, [max, porColumna, paso, moneda, alto, semilla]);
317
+ useLayoutEffect(() => {
318
+ if (ref.current) ref.current.count = Math.max(0, Math.min(max, Math.floor(n)));
319
+ });
320
+ return (
321
+ <group position={pos}>
322
+ <instancedMesh ref={ref} args={[undefined as any, undefined as any, max]} castShadow receiveShadow frustumCulled={false}>
323
+ {moneda ? <cylinderGeometry args={[0.5, 0.5, alto, 28]} /> : <boxGeometry args={[1.7, alto, 0.8]} />}
324
+ <meshStandardMaterial color={color ?? (moneda ? '#E8B64A' : '#7DBB86')} roughness={moneda ? 0.3 : 0.8} metalness={moneda ? 0.85 : 0.02} />
325
+ </instancedMesh>
326
+ </group>
327
+ );
328
+ };