506 lines
21 KiB
React
506 lines
21 KiB
React
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { Play, RotateCcw, AlertCircle } from 'lucide-react';
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Presets WGSL · cada uno es un shader completo autosuficiente
|
|
// ─────────────────────────────────────────────────────────────────
|
|
const SHADER_HEADER = `struct Uniforms {
|
|
time: f32,
|
|
resolution: vec2<f32>,
|
|
};
|
|
|
|
@group(0) @binding(0) var<uniform> u: Uniforms;
|
|
|
|
@vertex
|
|
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
|
// triángulo fullscreen (truco de tres vértices)
|
|
let p = array<vec2<f32>, 3>(
|
|
vec2<f32>(-1.0, -1.0),
|
|
vec2<f32>( 3.0, -1.0),
|
|
vec2<f32>(-1.0, 3.0)
|
|
);
|
|
return vec4<f32>(p[i], 0.0, 1.0);
|
|
}
|
|
`;
|
|
|
|
const PRESETS = {
|
|
plasma: SHADER_HEADER + `
|
|
@fragment
|
|
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
|
let uv = pos.xy / u.resolution;
|
|
let c = 0.5 + 0.5 * cos(u.time + uv.xyx + vec3<f32>(0.0, 2.0, 4.0));
|
|
return vec4<f32>(c, 1.0);
|
|
}
|
|
`,
|
|
|
|
circle: SHADER_HEADER + `
|
|
@fragment
|
|
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
|
// SDF de un círculo con anillo y fondo sutil
|
|
let uv = (pos.xy / u.resolution) * 2.0 - 1.0;
|
|
let aspect = u.resolution.x / u.resolution.y;
|
|
let p = vec2<f32>(uv.x * aspect, uv.y);
|
|
let r = 0.4 + 0.05 * sin(u.time * 2.0);
|
|
let d = length(p) - r;
|
|
let fill = smoothstep(0.0, -0.01, d);
|
|
let ring = smoothstep(0.015, 0.0, abs(d));
|
|
let bg = vec3<f32>(0.05, 0.06, 0.08);
|
|
let col = mix(bg, vec3<f32>(0.94, 0.55, 0.72), fill) + vec3<f32>(ring * 0.9);
|
|
return vec4<f32>(col, 1.0);
|
|
}
|
|
`,
|
|
|
|
checker: SHADER_HEADER + `
|
|
@fragment
|
|
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
|
// tablero rotando
|
|
let uv = pos.xy / u.resolution - 0.5;
|
|
let aspect = u.resolution.x / u.resolution.y;
|
|
let p0 = vec2<f32>(uv.x * aspect, uv.y);
|
|
let rot = u.time * 0.25;
|
|
let c = cos(rot);
|
|
let s = sin(rot);
|
|
let p = vec2<f32>(p0.x * c - p0.y * s, p0.x * s + p0.y * c) * 10.0;
|
|
let chk = (floor(p.x) + floor(p.y)) - 2.0 * floor((floor(p.x) + floor(p.y)) * 0.5);
|
|
let col = mix(vec3<f32>(0.93, 0.91, 0.86), vec3<f32>(0.10, 0.09, 0.13), chk);
|
|
return vec4<f32>(col, 1.0);
|
|
}
|
|
`,
|
|
|
|
waves: SHADER_HEADER + `
|
|
@fragment
|
|
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
|
// interferencia de dos ondas senoidales
|
|
let uv = pos.xy / u.resolution;
|
|
let a = sin(uv.x * 20.0 + u.time * 2.0);
|
|
let b = sin(uv.y * 15.0 - u.time * 1.3);
|
|
let v = 0.5 + 0.5 * a * b;
|
|
let col = vec3<f32>(v, pow(v, 2.0) * 0.5 + 0.3, 1.0 - v * 0.7);
|
|
return vec4<f32>(col, 1.0);
|
|
}
|
|
`,
|
|
|
|
sdfBlob: SHADER_HEADER + `
|
|
fn sdCircle(p: vec2<f32>, r: f32) -> f32 { return length(p) - r; }
|
|
fn smoothMin(a: f32, b: f32, k: f32) -> f32 {
|
|
let h = max(k - abs(a - b), 0.0) / k;
|
|
return min(a, b) - h * h * h * k * (1.0 / 6.0);
|
|
}
|
|
|
|
@fragment
|
|
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
|
// metaball · fusión de tres SDFs
|
|
let uv = (pos.xy / u.resolution) * 2.0 - 1.0;
|
|
let aspect = u.resolution.x / u.resolution.y;
|
|
let p = vec2<f32>(uv.x * aspect, uv.y);
|
|
let t = u.time;
|
|
let c1 = sdCircle(p - vec2<f32>(cos(t) * 0.4, sin(t * 1.3) * 0.3), 0.25);
|
|
let c2 = sdCircle(p - vec2<f32>(cos(t * 0.7 + 2.0) * 0.35, sin(t * 0.9) * 0.25), 0.22);
|
|
let c3 = sdCircle(p - vec2<f32>(sin(t * 1.1) * 0.3, cos(t * 0.6) * 0.35), 0.2);
|
|
let d = smoothMin(smoothMin(c1, c2, 0.35), c3, 0.35);
|
|
let fill = smoothstep(0.02, -0.02, d);
|
|
let glow = exp(-8.0 * max(d, 0.0));
|
|
let bg = vec3<f32>(0.04, 0.02, 0.06);
|
|
let core = vec3<f32>(0.95, 0.4, 0.6);
|
|
let col = bg + core * (fill * 0.9 + glow * 0.3);
|
|
return vec4<f32>(col, 1.0);
|
|
}
|
|
`,
|
|
};
|
|
|
|
const PRESET_ORDER = [
|
|
{ key: 'plasma', label: 'plasma', hint: 'cos-gradient clásico' },
|
|
{ key: 'circle', label: 'círculo sdf', hint: 'signed distance field' },
|
|
{ key: 'checker', label: 'tablero', hint: 'patrón rotando' },
|
|
{ key: 'waves', label: 'ondas', hint: 'interferencia senoidal' },
|
|
{ key: 'sdfBlob', label: 'metaball', hint: 'fusión suave de SDFs' },
|
|
];
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Hook: gestión de todo el ciclo WebGPU
|
|
// ─────────────────────────────────────────────────────────────────
|
|
function useWebGPU(canvasRef, code) {
|
|
const gpu = useRef({
|
|
device: null,
|
|
context: null,
|
|
format: null,
|
|
pipeline: null,
|
|
bindGroup: null,
|
|
uniformBuffer: null,
|
|
startTime: 0,
|
|
raf: 0,
|
|
});
|
|
const [status, setStatus] = useState('init'); // init · ready · unsupported · error
|
|
const [shaderError, setShaderError] = useState(null);
|
|
const [fps, setFps] = useState(0);
|
|
const [timeT, setTimeT] = useState(0);
|
|
|
|
// ── Inicialización ──
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
if (!navigator.gpu) { setStatus('unsupported'); return; }
|
|
const adapter = await navigator.gpu.requestAdapter();
|
|
if (!adapter) { setStatus('unsupported'); return; }
|
|
const device = await adapter.requestDevice();
|
|
if (cancelled) return;
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
canvas.width = 400;
|
|
canvas.height = 400;
|
|
const context = canvas.getContext('webgpu');
|
|
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
context.configure({ device, format, alphaMode: 'premultiplied' });
|
|
const uniformBuffer = device.createBuffer({
|
|
size: 16, // f32 time + pad + vec2<f32> resolution
|
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
});
|
|
gpu.current = {
|
|
...gpu.current,
|
|
device, context, format, uniformBuffer,
|
|
startTime: performance.now(),
|
|
};
|
|
device.lost.then(() => setStatus('error'));
|
|
setStatus('ready');
|
|
} catch (e) {
|
|
console.error(e);
|
|
setShaderError(String(e.message || e));
|
|
setStatus('error');
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [canvasRef]);
|
|
|
|
// ── Compilación del shader (debounced) ──
|
|
const compileShader = useCallback(async (wgsl) => {
|
|
const { device, format, uniformBuffer } = gpu.current;
|
|
if (!device) return;
|
|
|
|
device.pushErrorScope('validation');
|
|
const module = device.createShaderModule({ code: wgsl });
|
|
const info = await module.getCompilationInfo();
|
|
const errors = info.messages.filter(m => m.type === 'error');
|
|
if (errors.length > 0) {
|
|
const msg = errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n');
|
|
setShaderError(msg);
|
|
await device.popErrorScope();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const pipeline = device.createRenderPipeline({
|
|
layout: 'auto',
|
|
vertex: { module, entryPoint: 'vs' },
|
|
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
|
|
primitive:{ topology: 'triangle-list' },
|
|
});
|
|
const bindGroup = device.createBindGroup({
|
|
layout: pipeline.getBindGroupLayout(0),
|
|
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
|
|
});
|
|
gpu.current.pipeline = pipeline;
|
|
gpu.current.bindGroup = bindGroup;
|
|
setShaderError(null);
|
|
} catch (e) {
|
|
setShaderError(String(e.message || e));
|
|
}
|
|
const err = await device.popErrorScope();
|
|
if (err) setShaderError(err.message);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (status !== 'ready') return;
|
|
const id = setTimeout(() => compileShader(code), 180);
|
|
return () => clearTimeout(id);
|
|
}, [code, status, compileShader]);
|
|
|
|
// ── Render loop ──
|
|
useEffect(() => {
|
|
if (status !== 'ready') return;
|
|
let running = true;
|
|
let frames = 0;
|
|
let lastFpsSample = performance.now();
|
|
|
|
const loop = () => {
|
|
if (!running) return;
|
|
const { device, context, pipeline, bindGroup, uniformBuffer, startTime } = gpu.current;
|
|
const canvas = canvasRef.current;
|
|
|
|
if (device && context && pipeline && bindGroup && canvas) {
|
|
// redimensionado
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
|
|
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
|
|
if (canvas.width !== w || canvas.height !== h) {
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
}
|
|
|
|
const t = (performance.now() - startTime) / 1000;
|
|
const data = new Float32Array([t, 0, canvas.width, canvas.height]);
|
|
device.queue.writeBuffer(uniformBuffer, 0, data);
|
|
|
|
const encoder = device.createCommandEncoder();
|
|
const pass = encoder.beginRenderPass({
|
|
colorAttachments: [{
|
|
view: context.getCurrentTexture().createView(),
|
|
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
loadOp: 'clear',
|
|
storeOp: 'store',
|
|
}],
|
|
});
|
|
pass.setPipeline(pipeline);
|
|
pass.setBindGroup(0, bindGroup);
|
|
pass.draw(3);
|
|
pass.end();
|
|
device.queue.submit([encoder.finish()]);
|
|
|
|
frames++;
|
|
const now = performance.now();
|
|
if (now - lastFpsSample >= 500) {
|
|
setFps(Math.round((frames * 1000) / (now - lastFpsSample)));
|
|
setTimeT(t);
|
|
frames = 0;
|
|
lastFpsSample = now;
|
|
}
|
|
}
|
|
requestAnimationFrame(loop);
|
|
};
|
|
requestAnimationFrame(loop);
|
|
return () => { running = false; };
|
|
}, [status, canvasRef]);
|
|
|
|
const resetTime = useCallback(() => {
|
|
gpu.current.startTime = performance.now();
|
|
}, []);
|
|
|
|
return { status, shaderError, fps, timeT, resetTime };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// APP
|
|
// ─────────────────────────────────────────────────────────────────
|
|
export default function App() {
|
|
const canvasRef = useRef(null);
|
|
const [code, setCode] = useState(PRESETS.plasma);
|
|
const [activePreset, setActivePreset] = useState('plasma');
|
|
const { status, shaderError, fps, timeT, resetTime } = useWebGPU(canvasRef, code);
|
|
|
|
const loadPreset = (key) => {
|
|
setActivePreset(key);
|
|
setCode(PRESETS[key]);
|
|
};
|
|
|
|
const ACCENT = '#5eead4'; // teal-300
|
|
|
|
return (
|
|
<div className="w-full min-h-screen text-neutral-200" style={{
|
|
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
|
|
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
|
}}>
|
|
<style>{`
|
|
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
|
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
|
|
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
.grid-bg {
|
|
background-image:
|
|
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
|
background-size: 24px 24px;
|
|
}
|
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
|
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
|
|
textarea.wgsl {
|
|
tab-size: 2;
|
|
-moz-tab-size: 2;
|
|
}
|
|
`}</style>
|
|
|
|
<div className="grid-bg min-h-screen">
|
|
{/* HEADER */}
|
|
<header className="px-6 pt-6 pb-5 border-b border-white/5">
|
|
<div className="flex items-end justify-between flex-wrap gap-4">
|
|
<div>
|
|
<div className="flex items-baseline gap-3">
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 002</span>
|
|
<span className="font-mono text-[10px] text-neutral-700">/</span>
|
|
<span className="font-mono text-[10px] text-neutral-500">shaders · webgpu</span>
|
|
<span className="font-mono text-[10px] text-neutral-700">/</span>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
|
|
laboratorio de <em className="italic" style={{color: ACCENT}}>píxels</em>
|
|
</h1>
|
|
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
|
|
editor WGSL en vivo · cada tecla recompila el fragment shader · el uniform block expone <span style={{color: ACCENT}}>time</span> y <span style={{color: ACCENT}}>resolution</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono text-[10px] text-neutral-600">t = {timeT.toFixed(2)}s</span>
|
|
<span className="font-mono text-[10px] text-neutral-600">{fps} fps</span>
|
|
<button
|
|
onClick={resetTime}
|
|
className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
|
|
>
|
|
<RotateCcw size={11} /> reset t
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* MAIN · 2 columnas: canvas | editor */}
|
|
<main style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'minmax(280px, 1fr) minmax(340px, 1fr)',
|
|
minHeight: 'calc(100vh - 110px)',
|
|
overflowX: 'auto',
|
|
}}>
|
|
{/* IZQUIERDA · CANVAS */}
|
|
<section className="p-4 md:p-6 border-r border-white/5 flex flex-col gap-3" style={{maxHeight: 'calc(100vh - 110px)'}}>
|
|
<div className="flex items-baseline gap-3">
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">canvas</span>
|
|
<span className="font-mono text-[9px] text-neutral-600">fragment shader · fullscreen triangle</span>
|
|
</div>
|
|
<div
|
|
className="flex-1 rounded-xl border border-white/10 overflow-hidden relative"
|
|
style={{
|
|
background: '#000',
|
|
boxShadow: `0 0 0 1px ${ACCENT}10, 0 20px 60px -30px ${ACCENT}30`,
|
|
minHeight: '320px',
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="block"
|
|
style={{width: '100%', height: '100%'}}
|
|
/>
|
|
{status !== 'ready' && <StatusOverlay status={status} error={shaderError} />}
|
|
</div>
|
|
<div className="font-mono text-[9px] text-neutral-600">
|
|
consejo · el vertex shader genera un triángulo que cubre toda la pantalla. todo el trabajo interesante pasa en el <span style={{color: ACCENT}}>fragment</span>.
|
|
</div>
|
|
</section>
|
|
|
|
{/* DERECHA · EDITOR */}
|
|
<section className="p-4 md:p-6 flex flex-col gap-3" style={{maxHeight: 'calc(100vh - 110px)'}}>
|
|
{/* presets */}
|
|
<div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-2">presets</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{PRESET_ORDER.map(p => {
|
|
const active = p.key === activePreset;
|
|
return (
|
|
<button
|
|
key={p.key}
|
|
onClick={() => loadPreset(p.key)}
|
|
className="font-mono text-[11px] px-2.5 py-1 rounded transition-all"
|
|
style={{
|
|
background: active ? `${ACCENT}18` : 'rgba(255,255,255,0.02)',
|
|
border: `1px solid ${active ? ACCENT + '60' : 'rgba(255,255,255,0.08)'}`,
|
|
color: active ? ACCENT : '#a3a3a3',
|
|
}}
|
|
title={p.hint}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* editor */}
|
|
<div className="flex flex-col flex-1 gap-2 min-h-0">
|
|
<div className="flex items-baseline gap-3">
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">fuente · wgsl</span>
|
|
{shaderError ? (
|
|
<span className="font-mono text-[10px] text-rose-400 flex items-center gap-1">
|
|
<AlertCircle size={10} /> error de compilación
|
|
</span>
|
|
) : (
|
|
<span className="font-mono text-[10px]" style={{color: ACCENT}}>✓ compilado</span>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
className="wgsl flex-1 w-full rounded-lg p-3 font-mono text-[12px] leading-relaxed resize-none outline-none"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
spellCheck={false}
|
|
style={{
|
|
background: 'rgba(255,255,255,0.02)',
|
|
border: `1px solid ${shaderError ? '#f43f5e40' : 'rgba(255,255,255,0.08)'}`,
|
|
color: '#d4d4d4',
|
|
minHeight: '300px',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* errores */}
|
|
{shaderError && (
|
|
<div className="rounded-lg p-3" style={{
|
|
background: '#f43f5e0a',
|
|
border: '1px solid #f43f5e30',
|
|
}}>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-rose-400 mb-1.5 flex items-center gap-1.5">
|
|
<AlertCircle size={11} /> compilación fallida
|
|
</div>
|
|
<pre className="font-mono text-[11px] text-rose-300 whitespace-pre-wrap leading-relaxed">{shaderError}</pre>
|
|
</div>
|
|
)}
|
|
|
|
<div className="font-mono text-[9px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
|
|
uniforms · <span style={{color: ACCENT}}>u.time</span> (f32, segundos desde inicio) · <span style={{color: ACCENT}}>u.resolution</span> (vec2<f32>, px). el último pipeline válido se mantiene hasta que la próxima compilación tenga éxito.
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Sub-componentes
|
|
// ─────────────────────────────────────────────────────────────────
|
|
function StatusBadge({ status }) {
|
|
const map = {
|
|
init: { color: '#fbbf24', label: 'inicializando' },
|
|
ready: { color: '#5eead4', label: 'activo' },
|
|
unsupported: { color: '#f43f5e', label: 'sin webgpu' },
|
|
error: { color: '#f43f5e', label: 'error' },
|
|
};
|
|
const s = map[status] || map.init;
|
|
return (
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full" style={{background: s.color, boxShadow: `0 0 8px ${s.color}`}} />
|
|
<span className="font-mono text-[10px]" style={{color: s.color}}>{s.label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusOverlay({ status, error }) {
|
|
if (status === 'ready') return null;
|
|
return (
|
|
<div className="absolute inset-0 flex items-center justify-center p-6 text-center" style={{background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)'}}>
|
|
{status === 'init' && (
|
|
<div className="font-mono text-[11px] text-neutral-400">inicializando adaptador GPU…</div>
|
|
)}
|
|
{status === 'unsupported' && (
|
|
<div className="max-w-sm">
|
|
<div className="font-display text-xl italic mb-2" style={{color: '#f43f5e'}}>WebGPU no disponible</div>
|
|
<div className="font-mono text-[10px] text-neutral-400 leading-relaxed">
|
|
este navegador no expone <code>navigator.gpu</code>. prueba con chrome/edge recientes, o safari 18+, o activa el flag en firefox nightly.
|
|
</div>
|
|
</div>
|
|
)}
|
|
{status === 'error' && (
|
|
<div className="max-w-sm">
|
|
<div className="font-display text-xl italic mb-2" style={{color: '#f43f5e'}}>error de inicialización</div>
|
|
<div className="font-mono text-[10px] text-neutral-400 whitespace-pre-wrap">{error}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|