zigzag-engine/web/zigzag-renderer.jsx
Maximus Gorog c011af0414 Add visibility filter and output only visible elements
Added Point::is_visible() to explosion.rs:
- A point at geom_dim d is visible iff coords[d..] are all singular
- Matches homotopy.io's visibility filter (mesh.rs:111-115)

Updated render_braiding.rs:
- Filter to visible elements only (7 of 23 points for half_braid)
- Compute layout coordinates: x=time, y=height, z=depth
- Wires spread at z = [-1, 0, 1], vertices at z = [-0.5, 0.5]
- No volumes in output (not rendered)

Visible elements for half_braid:
- 2 vertices: (s0,s0,s0), (s1,s0,s0)
- 3 wires: (r0,s0,s0), (r1,s0,s0), (r2,s0,s0)
- 2 surfaces: (r0,r0,s0), (r0,r1,s0)

Updated web/zigzag-renderer.jsx with new geometry data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-08 02:16:36 -06:00

689 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Three.js renderer for zigzag-engine half_braid geometry
// Renders VISIBLE elements only from explosion poset
// Visibility: point at geom_dim d is visible iff coords[d..] are all singular
const GEOMETRY_DATA = {
"metadata": {
"source": "half_braid.json",
"dimension": 3,
"total_points": 23,
"visible_points": 7,
"total_covers": 35
},
"vertices": [
{ "id": 21, "label": "vertex_0", "point": "s0,s0,s0", "coords": [0.0, 0.0, -0.5] },
{ "id": 22, "label": "vertex_1", "point": "s1,s0,s0", "coords": [0.0, 0.0, 0.5] }
],
"wires": [
{ "id": 18, "label": "wire_0", "point": "r0,s0,s0", "coords": [0.0, 0.0, -1.0],
"endpoints": [21, 22], "endpoint_coords": [[0.0, 0.0, -0.5], [0.0, 0.0, 0.5]] },
{ "id": 19, "label": "wire_1", "point": "r1,s0,s0", "coords": [0.0, 0.0, 0.0],
"endpoints": [21, 22], "endpoint_coords": [[0.0, 0.0, -0.5], [0.0, 0.0, 0.5]] },
{ "id": 20, "label": "wire_2", "point": "r2,s0,s0", "coords": [0.0, 0.0, 1.0],
"endpoints": [21, 22], "endpoint_coords": [[0.0, 0.0, -0.5], [0.0, 0.0, 0.5]] }
],
"surfaces": [
{ "id": 16, "label": "surface_0", "point": "r0,r0,s0", "coords": [0.0, -1.0, -1.0], "boundary_wires": [18] },
{ "id": 17, "label": "surface_1", "point": "r0,r1,s0", "coords": [0.0, 0.0, -1.0], "boundary_wires": [18] }
]
};
// Coordinate mapping: coords are already [x, y, z] layout coordinates
// Scale for better visualization
const SCALE = 2.0;
function mapCoords(coords) {
return [coords[0] * SCALE, coords[1] * SCALE, coords[2] * SCALE];
}
// Wire colors by group
const WIRE_COLORS = {
input: 0x4fc3f7, // blue - r0 in third coord
crossing: 0xe040fb, // purple - s0 in third coord
output: 0x66bb6a, // green - r1 in third coord
selfloop: 0xff7043 // orange - self-loops
};
function getWireGroup(wire) {
if (wire.endpoints[0] === wire.endpoints[1]) return 'selfloop';
const point = wire.point;
const thirdCoord = point.split(',')[2];
if (thirdCoord === 'r0') return 'input';
if (thirdCoord === 's0') return 'crossing';
if (thirdCoord === 'r1') return 'output';
return 'crossing';
}
// Variational elastic curve solver
// Minimizes E = τ·Σ|Δp|² + β·Σ|Δ²p|² + κ·|p_mid - waypoint|²
function solveElasticCurve(p0, p1, waypoint, tau, beta, kappa, resolution) {
const n = resolution; // interior points
const total = n + 2; // including endpoints
// Build matrix A = τ·DᵀD + β·D²ᵀD² + κ·(waypoint spring at midpoint)
// DᵀD tridiagonal: main=2, off=-1
// D²ᵀD² pentadiagonal: [1, -4, 6, -4, 1]
// We solve for interior points only (indices 1..n)
// Matrix is n×n
const A = [];
const bx = new Array(n).fill(0);
const by = new Array(n).fill(0);
const bz = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
A[i] = new Array(n).fill(0);
}
// Build DᵀD (tension term)
for (let i = 0; i < n; i++) {
A[i][i] += 2 * tau;
if (i > 0) A[i][i-1] += -tau;
if (i < n-1) A[i][i+1] += -tau;
}
// Boundary contributions to RHS for tension term
bx[0] += tau * p0[0];
by[0] += tau * p0[1];
bz[0] += tau * p0[2];
bx[n-1] += tau * p1[0];
by[n-1] += tau * p1[1];
bz[n-1] += tau * p1[2];
// Build D²ᵀD² (bending term) - pentadiagonal
// For interior point i, D²p[i] = p[i-2] - 4p[i-1] + 6p[i] - 4p[i+1] + p[i+2]
for (let i = 0; i < n; i++) {
A[i][i] += 6 * beta;
if (i > 0) A[i][i-1] += -4 * beta;
if (i > 1) A[i][i-2] += beta;
if (i < n-1) A[i][i+1] += -4 * beta;
if (i < n-2) A[i][i+2] += beta;
}
// Boundary contributions for bending term
// At i=0: needs p[-1]=p0, p[-2] (extrapolate as p0)
// At i=1: needs p[-1]=p0
// At i=n-2: needs p[n]=p1
// At i=n-1: needs p[n]=p1, p[n+1] (extrapolate as p1)
// i=0 contributions from p0 (at index -1) and extrapolated p0 (at index -2)
bx[0] += beta * (4 * p0[0] - p0[0]); // -4*p[-1] + p[-2], but these go to RHS with sign flip
by[0] += beta * (4 * p0[1] - p0[1]);
bz[0] += beta * (4 * p0[2] - p0[2]);
if (n > 1) {
bx[1] += beta * p0[0];
by[1] += beta * p0[1];
bz[1] += beta * p0[2];
}
// i=n-1 contributions from p1 (at index n) and extrapolated p1 (at index n+1)
bx[n-1] += beta * (4 * p1[0] - p1[0]);
by[n-1] += beta * (4 * p1[1] - p1[1]);
bz[n-1] += beta * (4 * p1[2] - p1[2]);
if (n > 1) {
bx[n-2] += beta * p1[0];
by[n-2] += beta * p1[1];
bz[n-2] += beta * p1[2];
}
// Waypoint spring at midpoint
const midIdx = Math.floor(n / 2);
A[midIdx][midIdx] += kappa;
bx[midIdx] += kappa * waypoint[0];
by[midIdx] += kappa * waypoint[1];
bz[midIdx] += kappa * waypoint[2];
// Solve using Gaussian elimination with partial pivoting
function solveTridiagonal(A, b) {
const n = b.length;
const x = new Array(n);
const Ac = A.map(row => [...row]);
const bc = [...b];
// Forward elimination
for (let k = 0; k < n-1; k++) {
// Find pivot
let maxIdx = k;
for (let i = k+1; i < n; i++) {
if (Math.abs(Ac[i][k]) > Math.abs(Ac[maxIdx][k])) maxIdx = i;
}
// Swap rows
[Ac[k], Ac[maxIdx]] = [Ac[maxIdx], Ac[k]];
[bc[k], bc[maxIdx]] = [bc[maxIdx], bc[k]];
if (Math.abs(Ac[k][k]) < 1e-10) continue;
for (let i = k+1; i < n; i++) {
const factor = Ac[i][k] / Ac[k][k];
for (let j = k; j < n; j++) {
Ac[i][j] -= factor * Ac[k][j];
}
bc[i] -= factor * bc[k];
}
}
// Back substitution
for (let i = n-1; i >= 0; i--) {
let sum = bc[i];
for (let j = i+1; j < n; j++) {
sum -= Ac[i][j] * x[j];
}
x[i] = Math.abs(Ac[i][i]) > 1e-10 ? sum / Ac[i][i] : 0;
}
return x;
}
const solX = solveTridiagonal(A, bx);
const solY = solveTridiagonal(A, by);
const solZ = solveTridiagonal(A, bz);
// Build full curve with endpoints
const curve = [p0];
for (let i = 0; i < n; i++) {
curve.push([solX[i], solY[i], solZ[i]]);
}
curve.push(p1);
return curve;
}
// Generate self-loop curve (parametric loop offset toward waypoint)
function generateSelfLoop(center, waypoint, resolution) {
const points = [];
const offset = [
waypoint[0] - center[0],
waypoint[1] - center[1],
waypoint[2] - center[2]
];
const offsetMag = Math.sqrt(offset[0]**2 + offset[1]**2 + offset[2]**2);
const norm = offsetMag > 0.01 ? offset.map(x => x / offsetMag) : [0, 1, 0];
// Create perpendicular vectors for the loop plane
let perp1, perp2;
if (Math.abs(norm[1]) < 0.9) {
perp1 = [norm[2], 0, -norm[0]];
} else {
perp1 = [0, norm[2], -norm[1]];
}
const mag1 = Math.sqrt(perp1[0]**2 + perp1[1]**2 + perp1[2]**2);
perp1 = perp1.map(x => x / mag1);
perp2 = [
norm[1]*perp1[2] - norm[2]*perp1[1],
norm[2]*perp1[0] - norm[0]*perp1[2],
norm[0]*perp1[1] - norm[1]*perp1[0]
];
const loopRadius = Math.min(offsetMag * 0.6, 0.8);
const loopCenter = [
center[0] + norm[0] * offsetMag * 0.4,
center[1] + norm[1] * offsetMag * 0.4,
center[2] + norm[2] * offsetMag * 0.4
];
for (let i = 0; i <= resolution; i++) {
const t = (i / resolution) * 2 * Math.PI;
const x = loopCenter[0] + loopRadius * (Math.cos(t) * perp1[0] + Math.sin(t) * perp2[0]);
const y = loopCenter[1] + loopRadius * (Math.cos(t) * perp1[1] + Math.sin(t) * perp2[1]);
const z = loopCenter[2] + loopRadius * (Math.cos(t) * perp1[2] + Math.sin(t) * perp2[2]);
points.push([x, y, z]);
}
return points;
}
function ZigzagRenderer() {
const containerRef = React.useRef(null);
const sceneRef = React.useRef(null);
const cameraRef = React.useRef(null);
const rendererRef = React.useRef(null);
const wireObjectsRef = React.useRef([]);
const waypointObjectsRef = React.useRef([]);
const surfaceObjectsRef = React.useRef([]);
const labelObjectsRef = React.useRef([]);
const [tension, setTension] = React.useState(1.0);
const [bending, setBending] = React.useState(0.5);
const [kappa, setKappa] = React.useState(2.0);
const [resolution, setResolution] = React.useState(20);
const [showWaypoints, setShowWaypoints] = React.useState(true);
const [showSurfaces, setShowSurfaces] = React.useState(true);
const [showLabels, setShowLabels] = React.useState(true);
// Orbit controls state
const orbitRef = React.useRef({
theta: Math.PI / 4,
phi: Math.PI / 4,
radius: 12,
target: [2, 2, 2],
isDragging: false,
lastX: 0,
lastY: 0
});
// Initialize Three.js scene
React.useEffect(() => {
if (!containerRef.current || !window.THREE) return;
const THREE = window.THREE;
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0f);
sceneRef.current = scene;
// Camera
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100);
cameraRef.current = camera;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
containerRef.current.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Lights
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
const directional = new THREE.DirectionalLight(0xffffff, 0.8);
directional.position.set(5, 10, 5);
scene.add(directional);
// Add vertices as glowing spheres
const vertexGeom = new THREE.SphereGeometry(0.15, 32, 32);
const vertexMat = new THREE.MeshStandardMaterial({
color: 0xf0c040,
emissive: 0xf0c040,
emissiveIntensity: 0.5
});
GEOMETRY_DATA.vertices.forEach(v => {
const pos = mapCoords(v.coords);
const mesh = new THREE.Mesh(vertexGeom, vertexMat);
mesh.position.set(pos[0], pos[1], pos[2]);
scene.add(mesh);
});
// Grid helper
const gridHelper = new THREE.GridHelper(10, 10, 0x333344, 0x222233);
gridHelper.position.y = -0.5;
scene.add(gridHelper);
// Axes helper
const axesHelper = new THREE.AxesHelper(3);
scene.add(axesHelper);
// Mouse controls
const canvas = renderer.domElement;
canvas.addEventListener('mousedown', (e) => {
orbitRef.current.isDragging = true;
orbitRef.current.lastX = e.clientX;
orbitRef.current.lastY = e.clientY;
});
canvas.addEventListener('mousemove', (e) => {
if (!orbitRef.current.isDragging) return;
const dx = e.clientX - orbitRef.current.lastX;
const dy = e.clientY - orbitRef.current.lastY;
orbitRef.current.theta -= dx * 0.01;
orbitRef.current.phi = Math.max(0.1, Math.min(Math.PI - 0.1, orbitRef.current.phi + dy * 0.01));
orbitRef.current.lastX = e.clientX;
orbitRef.current.lastY = e.clientY;
});
canvas.addEventListener('mouseup', () => {
orbitRef.current.isDragging = false;
});
canvas.addEventListener('mouseleave', () => {
orbitRef.current.isDragging = false;
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
orbitRef.current.radius = Math.max(3, Math.min(30, orbitRef.current.radius + e.deltaY * 0.01));
});
// Animation loop
function animate() {
requestAnimationFrame(animate);
const orbit = orbitRef.current;
const x = orbit.target[0] + orbit.radius * Math.sin(orbit.phi) * Math.cos(orbit.theta);
const y = orbit.target[1] + orbit.radius * Math.cos(orbit.phi);
const z = orbit.target[2] + orbit.radius * Math.sin(orbit.phi) * Math.sin(orbit.theta);
camera.position.set(x, y, z);
camera.lookAt(orbit.target[0], orbit.target[1], orbit.target[2]);
renderer.render(scene, camera);
}
animate();
// Resize handler
const handleResize = () => {
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
renderer.dispose();
if (containerRef.current && renderer.domElement) {
containerRef.current.removeChild(renderer.domElement);
}
};
}, []);
// Update wires when parameters change
React.useEffect(() => {
if (!sceneRef.current || !window.THREE) return;
const THREE = window.THREE;
const scene = sceneRef.current;
// Remove old wire objects
wireObjectsRef.current.forEach(obj => scene.remove(obj));
wireObjectsRef.current = [];
// Create wire lookup for surfaces
const wireById = {};
// Add wires
GEOMETRY_DATA.wires.forEach(wire => {
const p0 = mapCoords(wire.endpoint_coords[0]);
const p1 = mapCoords(wire.endpoint_coords[1]);
const waypoint = mapCoords(wire.coords);
const group = getWireGroup(wire);
const color = WIRE_COLORS[group];
let curvePoints;
if (wire.endpoints[0] === wire.endpoints[1]) {
// Self-loop
curvePoints = generateSelfLoop(p0, waypoint, resolution);
} else {
// Elastic curve
curvePoints = solveElasticCurve(p0, p1, waypoint, tension, bending, kappa, resolution);
}
wireById[wire.id] = curvePoints;
// Create tube geometry
const points = curvePoints.map(p => new THREE.Vector3(p[0], p[1], p[2]));
const curve = new THREE.CatmullRomCurve3(points);
const tubeGeom = new THREE.TubeGeometry(curve, resolution * 2, 0.04, 8, false);
const tubeMat = new THREE.MeshStandardMaterial({
color: color,
emissive: color,
emissiveIntensity: 0.3
});
const tube = new THREE.Mesh(tubeGeom, tubeMat);
scene.add(tube);
wireObjectsRef.current.push(tube);
});
// Store wire paths for surface rendering
sceneRef.current.userData.wireById = wireById;
}, [tension, bending, kappa, resolution]);
// Update waypoint visibility
React.useEffect(() => {
if (!sceneRef.current || !window.THREE) return;
const THREE = window.THREE;
const scene = sceneRef.current;
// Remove old waypoint objects
waypointObjectsRef.current.forEach(obj => scene.remove(obj));
waypointObjectsRef.current = [];
if (showWaypoints) {
const waypointGeom = new THREE.SphereGeometry(0.08, 16, 16);
const waypointMat = new THREE.MeshStandardMaterial({
color: 0xf0c040,
emissive: 0xf0c040,
emissiveIntensity: 0.6,
transparent: true,
opacity: 0.8
});
GEOMETRY_DATA.wires.forEach(wire => {
const pos = mapCoords(wire.coords);
const mesh = new THREE.Mesh(waypointGeom, waypointMat);
mesh.position.set(pos[0], pos[1], pos[2]);
scene.add(mesh);
waypointObjectsRef.current.push(mesh);
});
}
}, [showWaypoints]);
// Update surface visibility
React.useEffect(() => {
if (!sceneRef.current || !window.THREE) return;
const THREE = window.THREE;
const scene = sceneRef.current;
// Remove old surface objects
surfaceObjectsRef.current.forEach(obj => scene.remove(obj));
surfaceObjectsRef.current = [];
if (showSurfaces) {
const surfaceMat = new THREE.MeshStandardMaterial({
color: 0xff6b6b,
emissive: 0xff6b6b,
emissiveIntensity: 0.4,
transparent: true,
opacity: 0.6
});
const lineMat = new THREE.LineBasicMaterial({
color: 0xff6b6b,
transparent: true,
opacity: 0.4
});
GEOMETRY_DATA.surfaces.forEach(surface => {
const pos = mapCoords(surface.coords);
// Surface center point
const sphereGeom = new THREE.SphereGeometry(0.06, 12, 12);
const sphere = new THREE.Mesh(sphereGeom, surfaceMat);
sphere.position.set(pos[0], pos[1], pos[2]);
scene.add(sphere);
surfaceObjectsRef.current.push(sphere);
// Lines to boundary wire midpoints
const wireById = scene.userData.wireById || {};
surface.boundary_wires.forEach(wireId => {
const wire = GEOMETRY_DATA.wires.find(w => w.id === wireId);
if (wire) {
const wirePos = mapCoords(wire.coords);
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(pos[0], pos[1], pos[2]),
new THREE.Vector3(wirePos[0], wirePos[1], wirePos[2])
]);
const line = new THREE.Line(geometry, lineMat);
scene.add(line);
surfaceObjectsRef.current.push(line);
}
});
});
}
}, [showSurfaces, tension, bending, kappa, resolution]);
// Update label visibility
React.useEffect(() => {
if (!sceneRef.current || !window.THREE) return;
const THREE = window.THREE;
const scene = sceneRef.current;
// Remove old label objects
labelObjectsRef.current.forEach(obj => scene.remove(obj));
labelObjectsRef.current = [];
if (showLabels) {
// Create canvas-based sprite labels
GEOMETRY_DATA.vertices.forEach(v => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 128;
canvas.height = 64;
ctx.fillStyle = 'rgba(10, 10, 15, 0.8)';
ctx.fillRect(0, 0, 128, 64);
ctx.strokeStyle = '#f0c040';
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, 124, 60);
ctx.font = 'bold 20px monospace';
ctx.fillStyle = '#f0c040';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(v.label, 64, 32);
const texture = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(spriteMat);
const pos = mapCoords(v.coords);
sprite.position.set(pos[0], pos[1] + 0.4, pos[2]);
sprite.scale.set(1, 0.5, 1);
scene.add(sprite);
labelObjectsRef.current.push(sprite);
});
}
}, [showLabels]);
const panelStyle = {
position: 'absolute',
top: '20px',
left: '20px',
background: 'rgba(10, 10, 20, 0.85)',
backdropFilter: 'blur(10px)',
padding: '20px',
borderRadius: '12px',
color: '#e0e0e0',
fontFamily: 'monospace',
fontSize: '14px',
border: '1px solid rgba(240, 192, 64, 0.3)',
minWidth: '220px'
};
const sliderStyle = {
width: '100%',
marginTop: '4px',
accentColor: '#f0c040'
};
const checkboxStyle = {
accentColor: '#f0c040',
marginRight: '8px'
};
return React.createElement('div', { style: { width: '100vw', height: '100vh', position: 'relative' } },
React.createElement('div', { ref: containerRef, style: { width: '100%', height: '100%' } }),
React.createElement('div', { style: panelStyle },
React.createElement('h3', { style: { margin: '0 0 15px 0', color: '#f0c040' } }, 'Zigzag Renderer'),
React.createElement('div', { style: { marginBottom: '12px' } },
React.createElement('label', null, `Tension τ: ${tension.toFixed(2)}`),
React.createElement('input', {
type: 'range', min: '0.1', max: '5', step: '0.1', value: tension,
onChange: (e) => setTension(parseFloat(e.target.value)),
style: sliderStyle
})
),
React.createElement('div', { style: { marginBottom: '12px' } },
React.createElement('label', null, `Bending β: ${bending.toFixed(2)}`),
React.createElement('input', {
type: 'range', min: '0', max: '2', step: '0.05', value: bending,
onChange: (e) => setBending(parseFloat(e.target.value)),
style: sliderStyle
})
),
React.createElement('div', { style: { marginBottom: '12px' } },
React.createElement('label', null, `Waypoint κ: ${kappa.toFixed(2)}`),
React.createElement('input', {
type: 'range', min: '0', max: '10', step: '0.1', value: kappa,
onChange: (e) => setKappa(parseFloat(e.target.value)),
style: sliderStyle
})
),
React.createElement('div', { style: { marginBottom: '12px' } },
React.createElement('label', null, `Resolution: ${resolution}`),
React.createElement('input', {
type: 'range', min: '5', max: '50', step: '1', value: resolution,
onChange: (e) => setResolution(parseInt(e.target.value)),
style: sliderStyle
})
),
React.createElement('hr', { style: { border: 'none', borderTop: '1px solid rgba(240, 192, 64, 0.3)', margin: '15px 0' } }),
React.createElement('div', { style: { marginBottom: '8px' } },
React.createElement('label', null,
React.createElement('input', {
type: 'checkbox', checked: showWaypoints,
onChange: (e) => setShowWaypoints(e.target.checked),
style: checkboxStyle
}),
'Waypoints'
)
),
React.createElement('div', { style: { marginBottom: '8px' } },
React.createElement('label', null,
React.createElement('input', {
type: 'checkbox', checked: showSurfaces,
onChange: (e) => setShowSurfaces(e.target.checked),
style: checkboxStyle
}),
'Surfaces'
)
),
React.createElement('div', { style: { marginBottom: '8px' } },
React.createElement('label', null,
React.createElement('input', {
type: 'checkbox', checked: showLabels,
onChange: (e) => setShowLabels(e.target.checked),
style: checkboxStyle
}),
'Labels'
)
),
React.createElement('hr', { style: { border: 'none', borderTop: '1px solid rgba(240, 192, 64, 0.3)', margin: '15px 0' } }),
React.createElement('div', { style: { fontSize: '11px', color: '#888' } },
React.createElement('div', null, '🔵 Input (r0)'),
React.createElement('div', null, '🟣 Crossing (s0)'),
React.createElement('div', null, '🟢 Output (r1)'),
React.createElement('div', null, '🟠 Self-loop')
)
)
);
}
// Export for use
window.ZigzagRenderer = ZigzagRenderer;