diff --git a/README.md b/README.md index 1abe030079..bd327af52c 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,32 @@ turbo build turbo build --filter=@pascal-app/core ``` +### Holtmont bridge tests + +The Holtmont Pre Work Order embeds this editor in an iframe and pushes a scene +in with `postMessage` (`apps/editor/app/holtmont-bridge.tsx`). Two suites guard +that path: + +```bash +# 1. Schema contract — every node of every generated scene must pass `AnyNode`. +bun test + +# 2. Render smoke test — drives the real editor in a browser and checks the +# geometry actually exists, not just the store. Needs the dev server up. +bun run --cwd apps/editor dev # in another terminal +node apps/editor/scripts/holtmont-smoke.mjs --screenshots=/tmp/holtmont +``` + +The scenes under `apps/editor/public/holtmont-fixtures/` are generated by +`scripts/generar_escenas_pascal.py` in the HOLTMONT-PYTHON repository; regenerate +them there whenever its scene builder changes. `/test-holtmont` is the manual +version of the same harness. + +Why the render smoke test exists: `useScene.setScene()` does not validate, so a +malformed node imports cleanly and only fails later inside a `useFrame`, which +kills the react-three-fiber render loop. The store looks fine and the canvas +goes black. Only a check on drawn geometry tells the two apart. + ### Publishing Packages ```bash diff --git a/apps/editor/app/holtmont-bridge.tsx b/apps/editor/app/holtmont-bridge.tsx new file mode 100644 index 0000000000..74f6c2d0e7 --- /dev/null +++ b/apps/editor/app/holtmont-bridge.tsx @@ -0,0 +1,115 @@ +'use client' + +import { sceneRegistry, useScene } from '@pascal-app/core' +import { CATALOG_ITEMS } from '@pascal-app/editor' +import { useEffect } from 'react' +import { Box3 } from 'three' +import { HoltmontImportError, normalizeHoltmontScene } from '../lib/holtmont-import' + +export function HoltmontBridge() { + useEffect(() => { + const isIframe = window.parent !== window.self + + function postToParent(payload: Record) { + if (isIframe) { + window.parent.postMessage(payload, '*') + } + } + + function handleMessage(event: MessageEvent) { + const data = event.data as Record | null + if (!data || data.type !== 'HOLTMONT_3D_IMPORT') return + + let scene: ReturnType + try { + // Valida contra el esquema real del editor antes de tocar el store: un + // nodo mal formado revienta después, dentro del bucle de render, y ahí + // ya no hay forma de avisar — solo queda el lienzo en negro. + scene = normalizeHoltmontScene(data.projectData, CATALOG_ITEMS) + } catch (err) { + const reason = err instanceof HoltmontImportError ? err.message : String(err) + console.error('[HoltmontBridge] HOLTMONT_3D_IMPORT rechazado:', reason) + // La escena que ya estaba montada se queda como está: sustituirla por + // una vacía convierte un import fallido en una pantalla negra. + postToParent({ type: 'HOLTMONT_3D_IMPORT_ERROR', reason, dropped: [] }) + return + } + + try { + console.log('[HoltmontBridge] HOLTMONT_3D_IMPORT recibido:', { + nodeCount: Object.keys(scene.nodes).length, + rootNodeIds: scene.rootNodeIds, + dropped: scene.dropped, + }) + + // setScene corre migraciones, quita huérfanos, marca todo sucio y avisa + // a los suscriptores. + useScene.getState().setScene(scene.nodes, scene.rootNodeIds) + + // setScene siempre deja collections en {}; se restauran si venían. + if (Object.keys(scene.collections).length > 0) { + useScene.setState({ collections: scene.collections as never }) + } + + postToParent({ + type: 'HOLTMONT_3D_IMPORT_ACK', + nodeCount: Object.keys(scene.nodes).length, + dropped: scene.dropped, + }) + } catch (err) { + console.error('[HoltmontBridge] No se pudo aplicar la escena importada:', err) + postToParent({ + type: 'HOLTMONT_3D_IMPORT_ERROR', + reason: String(err), + dropped: scene.dropped, + }) + } + } + + window.addEventListener('message', handleMessage) + + // Sonda de diagnóstico: dice qué se dibujó de verdad, no qué se guardó. + // La usa la prueba de humo (`scripts/holtmont-smoke.mjs`) para distinguir + // «la escena está en el store» de «la escena tiene geometría en pantalla», + // que es justo la diferencia entre el bug del lienzo negro y el arreglo. + ;(window as unknown as Record).__holtmontProbe = () => { + const nodes = useScene.getState().nodes + const resumen: Record = {} + let mayorLado = 0 + + for (const [tipo, ids] of Object.entries(sceneRegistry.byType)) { + const entrada = { total: 0, conGeometria: 0 } + for (const id of ids) { + if (!nodes[id as keyof typeof nodes]) continue + entrada.total += 1 + const objeto = sceneRegistry.nodes.get(id) + if (!objeto) continue + const caja = new Box3().setFromObject(objeto) + if (caja.isEmpty()) continue + const lado = Math.max( + caja.max.x - caja.min.x, + caja.max.y - caja.min.y, + caja.max.z - caja.min.z, + ) + if (lado > 0.01) { + entrada.conGeometria += 1 + mayorLado = Math.max(mayorLado, lado) + } + } + if (entrada.total > 0) resumen[tipo] = entrada + } + + return { nodos: Object.keys(nodes).length, porTipo: resumen, mayorLado } + } + + // Avisa al padre que el editor ya puede recibir escenas. + postToParent({ type: 'PASCAL_READY' }) + console.log('[HoltmontBridge] Listener montado — PASCAL_READY enviado') + + return () => { + window.removeEventListener('message', handleMessage) + } + }, []) + + return null +} diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 5257d59bf1..93e7b5b2e2 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -4,6 +4,7 @@ import { Barlow } from 'next/font/google' import localFont from 'next/font/local' import Script from 'next/script' import './globals.css' +import { HoltmontBridge } from './holtmont-bridge' const geistSans = localFont({ src: './fonts/GeistVF.woff', @@ -41,6 +42,7 @@ export default function RootLayout({ )} + {children} {process.env.NODE_ENV === 'development' && } diff --git a/apps/editor/app/test-holtmont/page.tsx b/apps/editor/app/test-holtmont/page.tsx new file mode 100644 index 0000000000..9a84a3b493 --- /dev/null +++ b/apps/editor/app/test-holtmont/page.tsx @@ -0,0 +1,182 @@ +'use client' + +/** + * Banco de pruebas del puente con Holtmont. + * + * Carga las escenas que genera el agente (`public/holtmont-fixtures/`, escritas + * por `scripts/generar_escenas_pascal.py` del repositorio HOLTMONT-PYTHON) y las + * manda al editor por el mismo `postMessage` que usa la Pre Work Order. Sirve a + * mano y también para la prueba de humo con navegador + * (`scripts/holtmont-smoke.mjs`), que entra con `?scene=` y lee el + * resultado de `window.__holtmontTest`. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' + +type Registro = { hora: string; texto: string; entrante: boolean } + +type EstadoDePrueba = { + ready: boolean + ack: Record | null + error: Record | null + enviada: string | null +} + +declare global { + interface Window { + __holtmontTest?: EstadoDePrueba + } +} + +export default function TestHoltmontPage() { + const iframeRef = useRef(null) + const [log, setLog] = useState([]) + const [ready, setReady] = useState(false) + const [escenas, setEscenas] = useState([]) + const [seleccionada, setSeleccionada] = useState('') + const estado = useRef({ ready: false, ack: null, error: null, enviada: null }) + + const anotar = useCallback((texto: string, entrante = false) => { + setLog((prev) => + [{ hora: new Date().toISOString().slice(11, 23), texto, entrante }, ...prev].slice(0, 60), + ) + }, []) + + const publicarEstado = useCallback(() => { + window.__holtmontTest = { ...estado.current } + }, []) + + // Catálogo de escenas disponibles y la que pide la URL (`?scene=`). + useEffect(() => { + publicarEstado() + const pedida = new URLSearchParams(window.location.search).get('scene') + fetch('/holtmont-fixtures/index.json') + .then((r) => r.json()) + .then((lista: string[]) => { + setEscenas(lista) + setSeleccionada(pedida && lista.includes(pedida) ? pedida : (lista[0] ?? '')) + }) + .catch((err) => anotar(`no se pudo leer el índice de escenas: ${err}`)) + }, [anotar, publicarEstado]) + + const enviarEscena = useCallback( + async (nombre: string) => { + const iframe = iframeRef.current + if (!iframe?.contentWindow || !nombre) return + estado.current = { ...estado.current, ack: null, error: null, enviada: null } + publicarEstado() + anotar(`→ HOLTMONT_3D_IMPORT (${nombre})`) + const projectData = await fetch(`/holtmont-fixtures/${nombre}.json`).then((r) => r.json()) + iframe.contentWindow.postMessage({ type: 'HOLTMONT_3D_IMPORT', projectData }, '*') + estado.current = { ...estado.current, enviada: nombre } + publicarEstado() + }, + [anotar, publicarEstado], + ) + + useEffect(() => { + const handler = (event: MessageEvent) => { + const data = event.data as Record | null + if (!data?.type) return + + if (data.type === 'PASCAL_READY') { + anotar('← PASCAL_READY', true) + setReady(true) + estado.current = { ...estado.current, ready: true } + } else if (data.type === 'HOLTMONT_3D_IMPORT_ACK') { + const descartes = (data.dropped as unknown[]) ?? [] + anotar( + `← HOLTMONT_3D_IMPORT_ACK (${data.nodeCount} nodos, ${descartes.length} descartes)`, + true, + ) + estado.current = { ...estado.current, ack: data } + } else if (data.type === 'HOLTMONT_3D_IMPORT_ERROR') { + anotar(`← HOLTMONT_3D_IMPORT_ERROR: ${data.reason}`, true) + estado.current = { ...estado.current, error: data } + } else if (data.type === 'HOLTMONT_3D_EXPORT') { + const exportData = data.data as Record + anotar(`← HOLTMONT_3D_EXPORT (${Object.keys(exportData?.nodes ?? {}).length} nodos)`, true) + } else { + return + } + publicarEstado() + } + + window.addEventListener('message', handler) + return () => window.removeEventListener('message', handler) + }, [anotar, publicarEstado]) + + // Autoenvío en cuanto el editor avisa que está listo: así la prueba de humo + // no depende de un temporizador. + useEffect(() => { + if (ready && seleccionada && !estado.current.enviada) { + void enviarEscena(seleccionada) + } + }, [ready, seleccionada, enviarEscena]) + + return ( +
+
+

Puente Holtmont — banco de pruebas

+ +
+ Editor: {ready ? '✓ listo' : 'esperando…'} +
+ + + + + +
Bitácora:
+
+ {log.map((entrada) => ( +
+ {entrada.hora} {entrada.texto} +
+ ))} +
+
+ +