builtin-programs/web/quads.folk

Wish the web server handles route "/quads" with handler {
    html {
        <!DOCTYPE html>
        <html>
        <head>
            <title>Folk Quads 3D</title>
            <script src="/lib/folk.js"></script>
            <script type="importmap">
            {
                "imports": {
                    "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
                    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
                }
            }
            </script>
            <style>
                body {
                    margin: 0;
                    overflow: hidden;
                    font-family: monospace;
                }
                #canvas-container {
                    width: 100vw;
                    height: 100vh;
                }
                #stats {
                    position: absolute;
                    top: 10px;
                    left: 10px;
                    color: white;
                    background: rgba(0, 0, 0, 0.7);
                    padding: 10px;
                    border-radius: 4px;
                    font-size: 14px;
                    line-height: 1.6;
                }
            </style>
        </head>
        <body>
            <div id="stats">
                <div>Quads: <span id="quad-count">0</span></div>
                <div>Space: <span id="camera-space">none</span></div>
            </div>
            <div id="canvas-container"></div>
            <script type="module">
                import * as THREE from 'three';
                import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

                // Initialize scene
                const scene = new THREE.Scene();
                scene.background = new THREE.Color(0x1a1a1a);

                // Initialize camera
                const camera = new THREE.PerspectiveCamera(
                    60,
                    window.innerWidth / window.innerHeight,
                    0.01,
                    100
                );
                camera.position.set(0, 0, -2);
                camera.lookAt(0, 0, 0);

                // Initialize renderer
                const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
                renderer.setSize(window.innerWidth, window.innerHeight);
                renderer.setPixelRatio(window.devicePixelRatio);
                document.getElementById('canvas-container').appendChild(renderer.domElement);

                // Initialize controls
                const controls = new OrbitControls(camera, renderer.domElement);
                controls.enableDamping = true;
                controls.dampingFactor = 0.05;
                controls.minDistance = 0.1;
                controls.maxDistance = 10;

                // Add lighting
                const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
                scene.add(ambientLight);

                const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4);
                directionalLight.position.set(1, 2, 1);
                scene.add(directionalLight);

                // Add grid helper (2m x 2m, 20 divisions = 10cm each)
                const gridHelper = new THREE.GridHelper(2, 20);
                scene.add(gridHelper);

                // Add axes helper (0.5m)
                const axesHelper = new THREE.AxesHelper(0.5);
                scene.add(axesHelper);

                // State management
                const quadMeshes = new Map(); // tag -> { mesh, sprite }
                let firstCameraSpace = null;
                window.allQuads = [];

                // Color generation
                function getColorForTag(tag) {
                    const hash = Math.abs(tag.split('').reduce((h, c) =>
                        ((h << 5) - h) + c.charCodeAt(0), 0));
                    const hue = (hash % 360) / 360;
                    return new THREE.Color().setHSL(hue, 0.7, 0.6);
                }

                // Compute centroid + normal offset for a quad's
                // vertices so the label sprite sits slightly above
                // the surface (label has smaller Z value) instead of
                // coplanar.
                function quadLabelPosition(vertices) {
                    const centroid = vertices.reduce(
                        (sum, v) => [sum[0] + v[0]/4, sum[1] + v[1]/4, sum[2] + v[2]/4],
                        [0, 0, 0]);
                    const v0 = new THREE.Vector3(...vertices[0]);
                    const v1 = new THREE.Vector3(...vertices[1]);
                    const v3 = new THREE.Vector3(...vertices[3]);
                    const normal = new THREE.Vector3()
                        .crossVectors(
                            new THREE.Vector3().subVectors(v1, v0),
                            new THREE.Vector3().subVectors(v3, v0))
                        .normalize();
                    return new THREE.Vector3(...centroid).addScaledVector(normal, -0.005);
                }

                // Create quad mesh from vertices
                function createQuadMesh(tag, vertices) {
                    const geometry = new THREE.BufferGeometry();

                    // vertices: [TL, TR, BR, BL]
                    // Create two triangles: [TL, TR, BL] and [TR, BR, BL]
                    const positions = new Float32Array([
                        ...vertices[0], ...vertices[1], ...vertices[3], // Triangle 1
                        ...vertices[1], ...vertices[2], ...vertices[3]  // Triangle 2
                    ]);

                    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
                    geometry.computeVertexNormals();

                    const color = getColorForTag(tag);
                    const material = new THREE.MeshBasicMaterial({
                        color,
                        side: THREE.DoubleSide,
                        transparent: true,
                        opacity: 0.8
                    });

                    return new THREE.Mesh(geometry, material);
                }

                // Create label sprite
                function createLabelSprite(text) {
                    const canvas = document.createElement('canvas');
                    const context = canvas.getContext('2d');
                    canvas.width = 512;
                    canvas.height = 128;

                    context.font = 'Bold 48px Arial';
                    context.fillStyle = 'white';
                    context.textAlign = 'center';
                    context.textBaseline = 'middle';
                    context.fillText(text, 256, 64);

                    const texture = new THREE.CanvasTexture(canvas);
                    const material = new THREE.SpriteMaterial({ map: texture, depthTest: false });
                    const sprite = new THREE.Sprite(material);
                    sprite.scale.set(0.2, 0.05, 1);

                    return sprite;
                }

                // Update statistics display
                function updateStatistics(count, space) {
                    document.getElementById('quad-count').textContent = count;
                    document.getElementById('camera-space').textContent = space || 'none';
                }

                // Update quads in the scene
                function updateQuads() {
                    // Filter quads by camera space
                    const filtered = firstCameraSpace
                        ? window.allQuads.filter(r => r.quad && r.quad[0] === firstCameraSpace)
                        : window.allQuads;

                    const currentTags = new Set(filtered.map(r => r.tag));

                    // Remove deleted quads
                    for (const [tag, objects] of quadMeshes.entries()) {
                        if (!currentTags.has(tag)) {
                            scene.remove(objects.mesh);
                            scene.remove(objects.sprite);
                            quadMeshes.delete(tag);
                        }
                    }

                    // Add or update quads
                    for (const result of filtered) {
                        const { tag, quad } = result;

                        if (!quad || quad.length < 2) continue;

                        const [space, vertices] = quad;

                        if (!vertices || vertices.length !== 4) continue;

                        const verticesKey = JSON.stringify(vertices);

                        if (!quadMeshes.has(tag)) {
                            const mesh = createQuadMesh(tag, vertices);
                            const sprite = createLabelSprite(tag);

                            sprite.position.copy(quadLabelPosition(vertices));

                            scene.add(mesh);
                            scene.add(sprite);
                            quadMeshes.set(tag, { mesh, sprite, verticesKey });
                        } else {
                            // Check if vertices changed
                            const objects = quadMeshes.get(tag);
                            if (objects.verticesKey !== verticesKey) {
                                // Update mesh geometry
                                const positions = new Float32Array([
                                    ...vertices[0], ...vertices[1], ...vertices[3], // Triangle 1
                                    ...vertices[1], ...vertices[2], ...vertices[3]  // Triangle 2
                                ]);
                                objects.mesh.geometry.setAttribute('position',
                                    new THREE.BufferAttribute(positions, 3));
                                objects.mesh.geometry.computeVertexNormals();

                                // Update sprite position
                                objects.sprite.position.copy(quadLabelPosition(vertices));

                                // Update stored vertices key
                                objects.verticesKey = verticesKey;
                            }
                        }
                    }

                    updateStatistics(quadMeshes.size, firstCameraSpace);
                }

                // Window resize handler
                window.addEventListener('resize', () => {
                    camera.aspect = window.innerWidth / window.innerHeight;
                    camera.updateProjectionMatrix();
                    renderer.setSize(window.innerWidth, window.innerHeight);
                });

                // Animation loop
                function animate() {
                    requestAnimationFrame(animate);
                    controls.update();
                    renderer.render(scene, camera);
                }
                animate();

                // Connect to Folk
                const ws = new FolkWS();

                // Watch for camera space
                ws.watchCollected("/someone/ claims camera /camera/ has width /w/ height /h/",
                    results => {
                        const newSpace = results[0]?.camera || null;
                        if (newSpace !== firstCameraSpace) {
                            firstCameraSpace = newSpace;
                            updateQuads();
                        }
                    }
                );

                // Watch for quads
                ws.watchCollected("/someone/ claims /tag/ has quad /quad/", results => {
                  allQuads = results.map(r => {
                    const r1 = {...r};
                    r1.quad = loadList(r.quad);
                    r1.quad[1] = loadList(r1.quad[1]).map(row => loadList(row));
                    return r1;
                  });
                  updateQuads();
                });
            </script>
        </body>
        </html>
    }
}