K3 ENGINE v0.3.0

Versión: 0.3.0
Estado: Blueprint de Desarrollo
Última actualización: Abril 2026
Autor: Santiago Bustelo

K3 Engine is licensed under the Apache License, Version 2.0. You may obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0


Resumen

K3 Engine es un motor de renderizado 3D experimental, exclusivamente CSS, para el navegador. Explora los límites de lo que se puede lograr usando HTML y CSS puros (sin WebGL, sin canvas) aprovechando las transformaciones 3D de CSS para construir objetos y escenas tridimensionales interactivas.

El proyecto nació de una pregunta simple: dados los asombrosos demos CSS producidos por la comunidad de programación creativa, ¿sería posible construir un motor 3D entero solo con CSS?

Inspiraciones y Agradecimientos

K3 se apoya sobre los hombros de la comunidad de arte y demos CSS. Los siguientes autores y sus trabajos publicados fueron inspiraciones directas para este proyecto, y sus técnicas informaron muchas de las decisiones arquitectónicas tomadas a lo largo del motor.

Su trabajo colectivo (que abarca trucos de perspectiva, magia con transform-origin, CSS matemático y animación con CSS puro) demostró que la capa de estilos del navegador puede ser una superficie de renderizado mucho más expresiva de lo que se le suele reconocer.


ÍNDICE DE CONTENIDOS

  1. Visión y Filosofía
  2. Análisis del Estado Actual
  3. Panorama de la Arquitectura
  4. Especificaciones de Componentes
  5. Contrato de API v1.0
  6. Suite de Pruebas y Criterios de Aceptación
  7. Limitaciones Conocidas

1. VISIÓN Y FILOSOFÍA

1.1 Qué es K3

K3 es un sistema 3D declarativo para objetos que viven DENTRO del documento, no separados de él.

Principios Fundamentales:

  1. DOM-primero: los objetos 3D son elementos HTML, no texturas de canvas
  2. Declarativo: el estado se describe, no se construye imperativamente
  3. Honesto: las limitaciones se documentan, no se ocultan
  4. Educativo: el código enseña, no solo funciona

1.2 Qué NO es K3

1.3 Público Objetivo

Primario:

Secundario:

1.4 Propuesta de Valor

Three.js: basado en canvas, fotorrealista, desconectado del DOM
K3: basado en DOM, estilizado, integrado con HTML

Cuándo usar K3:
- Documentación técnica con diagramas 3D
- Educación científica (moléculas, física, anatomía)
- Diagramas de arquitectura (diseño de sistemas)
- Personalización de productos (simple, estilizada)
- Juegos indies (estética flat-shading)

Cuándo usar Three.js:
- Renderizado fotorrealista
- Escenas grandes (1000+ objetos)
- Iluminación/sombras avanzadas
- Aplicaciones críticas de rendimiento

2. ANÁLISIS DEL ESTADO ACTUAL

2.1 Qué EXISTE Hoy (según dump.txt)

✅ Clases Núcleo:

✅ Primitivas:

✅ Sistemas:

✅ Editor (HARDCODEADO):

2.2 Qué está ROTO/FALTANTE

❌ Sin API Pública:

// Estos no existen:
K3.create()
K3.update()
K3.getState()
K3.serialize()

❌ Sin MutationObserver:

❌ Sin Manejo de Errores:

❌ Sin Propiedades Generativas:

<!-- Esto no funciona: -->
<k3-use type="gear" teeth="20"></k3-use>
<!-- Cambiar teeth="30" no reconstruye la geometría -->

❌ Sin Snapshots de Estado:

❌ El Editor es un Anti-patrón:


3. PANORAMA DE LA ARQUITECTURA

3.1 Capas del Sistema

┌─────────────────────────────────────────────┐
│  CÓDIGO DE USUARIO (HTML/JS)                 │
│  <k3-scene>, K3.create(), K3.update()        │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│  CAPA DE API PÚBLICA (v1.0 NUEVA)            │
│  - Creación (create, instantiate, clone)    │
│  - Gestión de Estado (get/set/update)       │
│  - Consultas (pick, bounds, hierarchy)      │
│  - Serialización (export/import)            │
│  - Validación (constraints)                 │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│  MOTOR NÚCLEO (existe, necesita refactor)    │
│  - K3Element (sistema TRS)                   │
│  - K3Definition (registro de plantillas)     │
│  - K3Use (instanciación Shadow DOM)          │
│  - Bus de Señales (rigging)                  │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│  PRIMITIVAS (existen, necesitan generativas) │
│  - K3Plane, K3Box, K3Sphere                  │
│  - K3Stack, K3Extrude                        │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│  NAVEGADOR (Transformaciones CSS 3D)         │
│  - preserve-3d                               │
│  - matrix3d()                                │
│  - backface-visibility                       │
└─────────────────────────────────────────────┘

3.2 Flujo de Datos

ACCIÓN DEL USUARIO → API PÚBLICA → VALIDACIÓN → PIPELINE DE ACTUALIZACIÓN → DOM → CSS → RENDER

Ejemplo: K3.update('box', { x: 100 })
    ↓
1. Validar: ¿x es un número? ¿Está dentro de las restricciones?
2. Aplicar: element.setAttribute('x', 100)
3. Transformar: element.updateTransform()
4. Evento: K3.emit('k3:change', { id, changes })
5. Render: el navegador aplica matrix3d()

3.3 Gestión de Estado

// EL ESTADO VIVE EN 3 LUGARES:

// 1. DOM (fuente de verdad)
<k3-box x="100" y="50" z="0"></k3-box>

// 2. Caché interna (rendimiento)
K3._stateManager._cache = {
    'box-1': {
        position: {x: 100, y: 50, z: 0},
        rotation: {rx: 0, ry: 0, rz: 0},
        bounds: {...},
        _dirty: false
    }
}

// 3. Computado (bajo demanda)
const state = K3.getState('box-1');
// Devuelve un snapshot congelado

CRÍTICO: el DOM es la fuente de verdad, la caché es optimización.

Las mutaciones directas del DOM se consideran fuera de contrato.
Ver: Addendum 01 — Política de Mutación del DOM


4. ESPECIFICACIONES DE COMPONENTES

4.1 K3.API (NUEVA - v1.0)

Ubicación: js/engine/k3-api.js

/**
 * API Pública de K3
 * La ÚNICA forma en que los usuarios deberían interactuar con K3.
 */

window.K3 = {
    // === CREACIÓN ===
    
    /**
     * Crea un elemento primitivo
     * @param {string} type - 'k3-plane'|'k3-box'|'k3-sphere'|'k3-stack'|'k3-extrude'
     * @param {object} props - Propiedades iniciales
     * @param {Element} parent - Padre opcional al que anexar
     * @returns {Element} Elemento creado con ID autogenerado
     */
    create(type, props = {}, parent = null) {
        const element = document.createElement(type);
        element.id = props.id || `k3-${type}-${Date.now()}`;
        
        // Aplicar props iniciales
        for (const [key, value] of Object.entries(props)) {
            if (key !== 'id') {
                element.setAttribute(key, value);
            }
        }
        
        // Inicializar
        if (element.render) {
            element.render();
        }
        
        if (parent) {
            parent.appendChild(element);
        }
        
        K3.emit('k3:create', { id: element.id, type });
        return element;
    },
    
    /**
     * Instancia una plantilla registrada
     * @param {string} templateName - Nombre de <k3-object name="...">
     * @param {object} props - Overrides de propiedades
     * @param {Element} parent - Padre opcional
     * @returns {Promise<Element>} Resuelve cuando la plantilla se cargó
     */
    async instantiate(templateName, props = {}, parent = null) {
        // Esperar la plantilla si no está cargada
        if (!K3.registry.has(templateName)) {
            await new Promise(resolve => {
                const handler = (e) => {
                    if (e.detail.name === templateName) {
                        document.removeEventListener('k3-def-registered', handler);
                        resolve();
                    }
                };
                document.addEventListener('k3-def-registered', handler);
            });
        }
        
        const use = document.createElement('k3-use');
        use.setAttribute('type', templateName);
        use.id = props.id || `k3-${templateName}-${Date.now()}`;
        
        // Aplicar props (variables CSS + atributos)
        for (const [key, value] of Object.entries(props)) {
            if (key.startsWith('--')) {
                use.style.setProperty(key, value);
            } else if (key !== 'id') {
                use.setAttribute(key, value);
            }
        }
        
        if (parent) {
            parent.appendChild(use);
        }
        
        // Disparar inicialización
        if (use.initInstance) {
            use.initInstance();
        }
        
        K3.emit('k3:create', { id: use.id, type: templateName });
        return use;
    },
    
    /**
     * Clona un elemento existente
     * @param {string} sourceId - ID del elemento a clonar
     * @param {object} props - Overrides de propiedades
     * @returns {Element} Elemento nuevo
     */
    clone(sourceId, props = {}) {
        const source = document.getElementById(sourceId);
        if (!source) throw new Error(`Elemento ${sourceId} no encontrado`);
        
        const clone = source.cloneNode(true);
        clone.id = props.id || `${sourceId}-clone-${Date.now()}`;
        
        // Aplicar overrides
        for (const [key, value] of Object.entries(props)) {
            if (key !== 'id') {
                clone.setAttribute(key, value);
            }
        }
        
        return clone;
    },
    
    // === GESTIÓN DE ESTADO ===
    
    /**
     * Obtener snapshot inmutable del estado del elemento
     * @param {string} id - ID del elemento
     * @returns {object} Objeto de estado congelado
     */
    getState(id) {
        return K3._stateManager.getState(id);
    },
    
    /**
     * Restaurar el elemento a un estado previo
     * @param {string} id - ID del elemento
     * @param {object} state - Estado de getState()
     */
    setState(id, state) {
        K3._stateManager.setState(id, state);
    },
    
    /**
     * Actualizar propiedades del elemento (EL MÉTODO NÚCLEO)
     * @param {string} id - ID del elemento
     * @param {object} changes - Propiedades a cambiar
     * @param {object} options - { immediate: boolean, force: boolean }
     * @returns {object} { success: boolean, errors: array, applied: object }
     */
    update(id, changes, options = {}) {
        return K3._updatePipeline.update(id, changes, options);
    },
    
    /**
     * Actualizar múltiples elementos en lote atómicamente
     * @param {array} updates - [{id, changes}, ...]
     * @returns {array} Resultados de cada actualización
     */
    batch(updates) {
        return K3._updatePipeline.batch(updates);
    },
    
    /**
     * Forzar re-sincronización desde el DOM (usar con moderación)
     * @param {string} id - ID del elemento
     */
    sync(id) {
        K3._stateManager.markDirty(id);
        K3._stateManager.getState(id); // Fuerza recomputar
    },
    
    // === HELPERS DE TRANSFORMACIÓN ===
    
    setPosition(id, {x, y, z}) {
        return K3.update(id, {x, y, z});
    },
    
    setRotation(id, {rx, ry, rz}) {
        return K3.update(id, {rx, ry, rz});
    },
    
    setScale(id, scale) {
        if (typeof scale === 'number') {
            return K3.update(id, {scale});
        }
        return K3.update(id, scale); // {sx, sy, sz}
    },
    
    translate(id, delta) {
        const state = K3.getState(id);
        return K3.update(id, {
            x: state.position.x + (delta.x || 0),
            y: state.position.y + (delta.y || 0),
            z: state.position.z + (delta.z || 0)
        });
    },
    
    rotate(id, delta) {
        const state = K3.getState(id);
        return K3.update(id, {
            rx: state.rotation.rx + (delta.rx || 0),
            ry: state.rotation.ry + (delta.ry || 0),
            rz: state.rotation.rz + (delta.rz || 0)
        });
    },
    
    // === CONSULTAS ===
    
    /**
     * Obtener bounding box (local + world)
     * @param {string} id - ID del elemento
     * @returns {object} { local: {...}, world: {...} }
     */
    getBounds(id) {
        return K3.getState(id).bounds;
    },
    
    /**
     * Obtener restricciones del esquema
     * @param {string} id - ID del elemento
     * @returns {object} { position: {...}, rotation: {...}, forbidden: [...] }
     */
    getConstraints(id) {
        return K3._constraintSystem.getConstraints(id);
    },
    
    /**
     * Raycast para encontrar el elemento en coordenadas de pantalla
     * @param {number} x - clientX
     * @param {number} y - clientY
     * @param {object} options - { mode: 'root'|'leaf', filter: fn }
     * @returns {Element|null}
     */
    pick(x, y, options = {}) {
        return K3._spatialQuery.pick(x, y, options);
    },
    
    /**
     * Consultar elementos con filtros
     * @param {string} selector - Selector CSS
     * @param {object} filters - { type: string, bounds: {...} }
     * @returns {Element[]}
     */
    query(selector, filters = {}) {
        return K3._spatialQuery.query(selector, filters);
    },
    
    /**
     * Encontrar todas las instancias de un tipo
     * @param {string} type - Nombre de plantilla
     * @returns {Element[]}
     */
    findByType(type) {
        return Array.from(document.querySelectorAll(`k3-use[type="${type}"]`));
    },
    
    /**
     * Obtener hijos K3 de un elemento
     * @param {string} id - ID del elemento padre
     * @param {boolean} recursive - Incluir descendientes
     * @returns {string[]} Array de IDs de hijos
     */
    findChildren(id, recursive = false) {
        return K3._hierarchyManager.getChildren(id, recursive);
    },
    
    // === SERIALIZACIÓN ===
    
    /**
     * Exportar elemento a string HTML
     * @param {string} id - ID del elemento
     * @returns {string} Fragmento HTML
     */
    serialize(id) {
        return K3._serializer.toHTML(id);
    },
    
    /**
     * Crear elemento a partir de string HTML
     * @param {string} html - Fragmento HTML
     * @returns {Element|Element[]}
     */
    deserialize(html) {
        return K3._serializer.fromHTML(html);
    },
    
    /**
     * Exportar en distintos formatos
     * @param {string} id - ID del elemento
     * @param {string} format - 'html'|'json'
     * @returns {string|object}
     */
    export(id, format = 'html') {
        if (format === 'html') return K3.serialize(id);
        if (format === 'json') return K3._serializer.toJSON(id);
        throw new Error(`Formato desconocido: ${format}`);
    },
    
    /**
     * Importar desde distintos formatos
     * @param {string|object} data - Datos a importar
     * @param {string} format - 'html'|'json'
     * @returns {Element|Element[]}
     */
    import(data, format = 'html') {
        if (format === 'html') return K3.deserialize(data);
        if (format === 'json') return K3._serializer.fromJSON(data);
        throw new Error(`Formato desconocido: ${format}`);
    },
    
    // === VALIDACIÓN ===
    
    /**
     * Validar un valor antes de aplicarlo
     * @param {string} id - ID del elemento
     * @param {string} attr - Nombre del atributo
     * @param {any} value - Valor a validar
     * @returns {object} { valid: boolean, corrected: any, error: string }
     */
    validate(id, attr, value) {
        const element = document.getElementById(id);
        return K3._validator.validate(element, attr, value);
    },
    
    /**
     * Verificar si el elemento viola restricciones
     * @param {string} id - ID del elemento
     * @returns {object} { valid: boolean, violations: array }
     */
    checkConstraints(id) {
        return K3._constraintSystem.check(id);
    },
    
    // === SEÑALES (RIGGING) ===
    
    signals: {
        emit(target, name, value) {
            const event = new CustomEvent('k3-signal', {
                detail: { name, value: Math.max(0, Math.min(1, value)) },
                bubbles: true
            });
            
            if (typeof target === 'string') {
                target = document.getElementById(target);
            }
            
            target.dispatchEvent(event);
        },
        
        register(target, name, callback) {
            if (typeof target === 'string') {
                target = document.getElementById(target);
            }
            
            const handler = (e) => {
                if (e.detail.name === name) {
                    callback(e.detail.value);
                }
            };
            
            target.addEventListener('k3-signal', handler);
            return () => target.removeEventListener('k3-signal', handler);
        },
        
        unregister(target, name) {
            // El handler devuelto por register() ya es la limpieza
        }
    },
    
    // === EVENTOS ===
    
    /**
     * Escuchar eventos de K3
     * @param {string} event - 'k3:ready'|'k3:change'|'k3:create'|'k3:error'
     * @param {function} callback - Handler
     */
    on(event, callback) {
        document.addEventListener(event, callback);
    },
    
    off(event, callback) {
        document.removeEventListener(event, callback);
    },
    
    once(event, callback) {
        const handler = (e) => {
            callback(e);
            document.removeEventListener(event, handler);
        };
        document.addEventListener(event, handler);
    },
    
    /**
     * Emitir evento K3
     * @param {string} event - Nombre del evento
     * @param {object} detail - Datos del evento
     */
    emit(event, detail) {
        document.dispatchEvent(new CustomEvent(event, { detail }));
    },
    
    // === REGISTRO (SOLO LECTURA) ===
    
    registry: new Proxy({}, {
        get(target, prop) {
            if (prop === 'has') {
                return (name) => window.K3._internalRegistry.has(name);
            }
            if (prop === 'get') {
                return (name) => window.K3._internalRegistry.get(name);
            }
            if (prop === 'list') {
                return () => Array.from(window.K3._internalRegistry.keys());
            }
            return undefined;
        },
        set() {
            throw new Error('K3.registry es de solo lectura');
        }
    }),
    
    // Registro interno (no expuesto)
    _internalRegistry: new Map(),
    
    // === CONFIGURACIÓN ===
    
    config: {
        coordSystem: 'native',  // 'native' o 'cartesian'
        maxSlices: 100,
        debug: false,
        snapToGrid: false,
        gridSize: 10
    },
    
    // === UTILS ===
    
    utils: {
        normalizeAngle(degrees) {
            let angle = degrees % 360;
            if (angle < 0) angle += 360;
            return angle;
        },
        
        lerpColor(c1, c2, t) {
            // Implementación desde ColorUtils
            return ColorUtils.lerpColor(
                ColorUtils.parse(c1),
                ColorUtils.parse(c2),
                t
            );
        },
        
        degToRad(deg) {
            return deg * Math.PI / 180;
        },
        
        radToDeg(rad) {
            return rad * 180 / Math.PI;
        }
    }
};

// Inicializar sistemas internos
K3._stateManager = new K3StateManager();
K3._updatePipeline = new K3UpdatePipeline();
K3._validator = new K3Validator();
K3._constraintSystem = new K3ConstraintSystem();
K3._spatialQuery = new K3SpatialQuery();
K3._hierarchyManager = new K3HierarchyManager();
K3._serializer = new K3Serializer();

// Congelar superficie pública
Object.freeze(K3.create);
Object.freeze(K3.update);
Object.freeze(K3.getState);
// ... congelar todos los métodos públicos

4.2 K3StateManager (NUEVA - v1.0)

Ubicación: js/engine/core/k3-state-manager.js

/**
 * Gestor de Estado de K3
 * Administra snapshots de estado y seguimiento de "sucios".
 */

export class K3StateManager {
    constructor() {
        this._cache = new Map(); // id -> state
        this._dirty = new Set();  // ids que necesitan recomputarse
    }
    
    /**
     * Obtener snapshot inmutable del estado
     * @param {string} id - ID del elemento
     * @returns {object} Estado congelado
     */
    getState(id) {
        const element = document.getElementById(id);
        if (!element) {
            throw new Error(`Elemento ${id} no encontrado`);
        }
        
        // Devolver caché si no está sucio
        if (!this._dirty.has(id) && this._cache.has(id)) {
            return this._cache.get(id);
        }
        
        // Computar estado fresco
        const state = this._computeState(element);
        this._cache.set(id, state);
        this._dirty.delete(id);
        
        return state;
    }
    
    /**
     * Restaurar el elemento al estado
     * @param {string} id - ID del elemento
     * @param {object} state - Estado previo
     */
    setState(id, state) {
        const element = document.getElementById(id);
        if (!element) {
            throw new Error(`Elemento ${id} no encontrado`);
        }
        
        // Aplicar posición
        if (state.position) {
            element.setAttribute('x', state.position.x);
            element.setAttribute('y', state.position.y);
            element.setAttribute('z', state.position.z);
        }
        
        // Aplicar rotación
        if (state.rotation) {
            element.setAttribute('rx', state.rotation.rx);
            element.setAttribute('ry', state.rotation.ry);
            element.setAttribute('rz', state.rotation.rz);
        }
        
        // Aplicar escala
        if (state.scale) {
            if (state.scale.uniform !== undefined) {
                element.setAttribute('scale', state.scale.uniform);
            } else {
                element.setAttribute('sx', state.scale.sx);
                element.setAttribute('sy', state.scale.sy);
                element.setAttribute('sz', state.scale.sz);
            }
        }
        
        // Aplicar geometría (si cambió y es generativa)
        if (state.geometry) {
            for (const [key, value] of Object.entries(state.geometry)) {
                element.setAttribute(key, value);
            }
        }
        
        // Aplicar apariencia
        if (state.appearance) {
            for (const [key, value] of Object.entries(state.appearance)) {
                element.setAttribute(key, value);
            }
        }
        
        // Actualizar transformación
        if (element.updateTransform) {
            element.updateTransform();
        }
        
        this.markDirty(id);
    }
    
    /**
     * Marcar el elemento como que necesita recomputarse
     * @param {string} id - ID del elemento
     */
    markDirty(id) {
        this._dirty.add(id);
        
        // Marcar hijos sucios (las transformaciones se propagan)
        const children = this._getK3Children(id);
        children.forEach(childId => this._dirty.add(childId));
    }
    
    /**
     * Computar estado fresco desde el DOM
     * @private
     */
    _computeState(element) {
        const id = element.id;
        const tagName = element.tagName.toLowerCase();
        
        // Estado base
        const state = {
            id,
            tagName,
            type: element.getAttribute('type') || null,
            
            position: {
                x: parseFloat(element.getAttribute('x') || 0),
                y: parseFloat(element.getAttribute('y') || 0),
                z: parseFloat(element.getAttribute('z') || 0)
            },
            
            rotation: {
                rx: parseFloat(element.getAttribute('rx') || 0),
                ry: parseFloat(element.getAttribute('ry') || 0),
                rz: parseFloat(element.getAttribute('rz') || 0)
            },
            
            scale: {
                sx: parseFloat(element.getAttribute('sx') || element.getAttribute('scale') || 1),
                sy: parseFloat(element.getAttribute('sy') || element.getAttribute('scale') || 1),
                sz: parseFloat(element.getAttribute('sz') || element.getAttribute('scale') || 1),
                uniform: parseFloat(element.getAttribute('scale') || 1)
            }
        };
        
        // Geometría (específica por tipo)
        state.geometry = this._getGeometry(element);
        
        // Apariencia
        state.appearance = this._getAppearance(element);
        
        // Bounds
        state.bounds = this._computeBounds(element);
        
        // Esquema (para k3-use)
        if (tagName === 'k3-use' && state.type) {
            const def = window.K3._internalRegistry.get(state.type);
            state.schema = def?.schema || null;
        }
        
        // Restricciones
        state.constraints = this