>
)
}
From cd4936eff4b8160df20c41ba0a5f4ec35a4f8e07 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 8 Sep 2026 19:15:38 +0000
Subject: [PATCH 3/3] El plano 3D se dibuja: valida lo que llega y detecta bien
el backend
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
El Plano 3D de la Pre Work Order salía en negro con cualquier diseño. Eran dos
fallos independientes, y los dos dejaban el mismo síntoma: la interfaz entera
bien y el lienzo vacío, sin ningún aviso.
1. WebGPU se daba por disponible con `'gpu' in navigator`. Chromium expone
`navigator.gpu` en máquinas donde el dispositivo no se puede crear, y ahí
three cae solo a WebGL2 ("WebGPURenderer: WebGPU is not available, running
under WebGL2 backend") mientras el visor seguía montando la tubería TSL de
post-proceso, que es WebGPU puro. Resultado: nada dibujado y la consola
llena de "GL_INVALID_OPERATION: Feedback loop formed between Framebuffer and
active Texture". Ahora se comprueba el backend que quedó
(`renderer.backend.isWebGPUBackend`), que para cuando corre el efecto ya es
el definitivo porque el Canvas espera a `renderer.init()`.
2. `useScene.setScene()` no valida: guarda el objeto tal cual. Un nodo con un
campo de la forma equivocada —una puerta con `position: 0.5` en vez de
`[x, y, z]`, que es lo que mandaba el generador de Holtmont— importaba sin
quejarse y reventaba después dentro del `useFrame` de `DoorSystem`, matando
el bucle de render. El puente ahora pasa cada nodo por `AnyNode` antes de
tocar el store: rellena los valores por defecto que `setScene` no rellena,
descarta lo que no se puede dibujar y responde con `HOLTMONT_3D_IMPORT_ACK`
(con la lista de descartes) o `HOLTMONT_3D_IMPORT_ERROR`. Una escena que no
deja ningún nodo en pie se rechaza y la anterior se queda en pantalla, en
vez de sustituirla por una vacía.
De paso:
- `LevelNode.children` no admitía ids de `item`, aunque su propia descripción
los nombra y la herramienta de muebles crea los nodos con el nivel como
padre. Un nivel amueblado no pasaba `LevelNode.parse()`.
- Los muebles llegan con el nombre en `metadata.holtmontAsset` y el puente los
resuelve contra `CATALOG_ITEMS`: el catálogo de modelos vive aquí, no en el
generador.
Pruebas
- `bun test` (13): cada nodo de cada escena de `public/holtmont-fixtures/`
contra el Zod real, más las escenas rotas (puerta con `position` numérico,
tipo desconocido, mensaje sin nodos, huérfanos en cascada).
- `node apps/editor/scripts/holtmont-smoke.mjs`: abre el editor en un navegador,
le manda las cinco escenas por `postMessage` y comprueba la geometría
dibujada, no el store. Las cinco pasan: cuarto 5x4 con puerta, bodega 12x8 con
dos puertas y cuatro ventanas, oficina sin vanos, casa de dos pisos con
ventanas al frente y atrás (dos niveles, escalera y techo) y cuarto amueblado.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012KkjqdcUER3S3GX2ahziMQ
---
README.md | 26 +
apps/editor/app/holtmont-bridge.tsx | 103 ++-
apps/editor/app/test-holtmont/page.tsx | 324 +++++-----
apps/editor/lib/holtmont-import.test.ts | 197 ++++++
apps/editor/lib/holtmont-import.ts | 283 +++++++++
apps/editor/package.json | 4 +-
...dega-12x8-dos-puertas-cuatro-ventanas.json | 400 ++++++++++++
...asa-dos-pisos-ventanas-frente-y-atras.json | 601 ++++++++++++++++++
.../cuarto-5x4-con-puerta.json | 277 ++++++++
.../holtmont-fixtures/cuarto-amueblado.json | 386 +++++++++++
.../public/holtmont-fixtures/index.json | 7 +
.../holtmont-fixtures/oficina-sin-vanos.json | 252 ++++++++
apps/editor/scripts/holtmont-smoke.mjs | 183 ++++++
bun.lock | 5 +
package.json | 1 +
packages/core/package.json | 5 +
packages/core/src/schema/nodes/level.ts | 6 +
.../src/components/viewer/post-processing.tsx | 27 +-
18 files changed, 2868 insertions(+), 219 deletions(-)
create mode 100644 apps/editor/lib/holtmont-import.test.ts
create mode 100644 apps/editor/lib/holtmont-import.ts
create mode 100644 apps/editor/public/holtmont-fixtures/bodega-12x8-dos-puertas-cuatro-ventanas.json
create mode 100644 apps/editor/public/holtmont-fixtures/casa-dos-pisos-ventanas-frente-y-atras.json
create mode 100644 apps/editor/public/holtmont-fixtures/cuarto-5x4-con-puerta.json
create mode 100644 apps/editor/public/holtmont-fixtures/cuarto-amueblado.json
create mode 100644 apps/editor/public/holtmont-fixtures/index.json
create mode 100644 apps/editor/public/holtmont-fixtures/oficina-sin-vanos.json
create mode 100644 apps/editor/scripts/holtmont-smoke.mjs
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
index 9114e9aedb..74f6c2d0e7 100644
--- a/apps/editor/app/holtmont-bridge.tsx
+++ b/apps/editor/app/holtmont-bridge.tsx
@@ -1,7 +1,10 @@
'use client'
-import { useScene } from '@pascal-app/core'
+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(() => {
@@ -17,47 +20,91 @@ export function HoltmontBridge() {
const data = event.data as Record | null
if (!data || data.type !== 'HOLTMONT_3D_IMPORT') return
+ let scene: ReturnType
try {
- const projectData = data.projectData as Record | undefined
- if (!projectData?.nodes) {
- console.warn('[HoltmontBridge] HOLTMONT_3D_IMPORT: missing projectData.nodes — ignored')
- return
- }
+ // 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
+ }
- const nodes = projectData.nodes as Record
- const rootNodeIds = Array.isArray(projectData.rootNodeIds)
- ? (projectData.rootNodeIds as string[])
- : []
- const collections =
- projectData.collections && typeof projectData.collections === 'object'
- ? (projectData.collections as Record)
- : {}
-
- console.log('[HoltmontBridge] Received HOLTMONT_3D_IMPORT:', {
- nodeCount: Object.keys(nodes).length,
- rootNodeIds,
+ try {
+ console.log('[HoltmontBridge] HOLTMONT_3D_IMPORT recibido:', {
+ nodeCount: Object.keys(scene.nodes).length,
+ rootNodeIds: scene.rootNodeIds,
+ dropped: scene.dropped,
})
- // setScene runs migrations, removes orphans, marks all nodes dirty, notifies subscribers
- useScene.getState().setScene(nodes as any, rootNodeIds)
+ // setScene corre migraciones, quita huérfanos, marca todo sucio y avisa
+ // a los suscriptores.
+ useScene.getState().setScene(scene.nodes, scene.rootNodeIds)
- // setScene always resets collections to {}; restore them if present
- if (Object.keys(collections).length > 0) {
- useScene.setState({ collections: collections as any })
+ // setScene siempre deja collections en {}; se restauran si venían.
+ if (Object.keys(scene.collections).length > 0) {
+ useScene.setState({ collections: scene.collections as never })
}
- console.log('[HoltmontBridge] Scene applied — sending HOLTMONT_3D_IMPORT_ACK')
- postToParent({ type: 'HOLTMONT_3D_IMPORT_ACK' })
+ postToParent({
+ type: 'HOLTMONT_3D_IMPORT_ACK',
+ nodeCount: Object.keys(scene.nodes).length,
+ dropped: scene.dropped,
+ })
} catch (err) {
- console.error('[HoltmontBridge] Failed to apply imported scene:', 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)
- // Signal to parent that the editor is ready to receive scenes
+ // 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 mounted — PASCAL_READY sent')
+ console.log('[HoltmontBridge] Listener montado — PASCAL_READY enviado')
return () => {
window.removeEventListener('message', handleMessage)
diff --git a/apps/editor/app/test-holtmont/page.tsx b/apps/editor/app/test-holtmont/page.tsx
index 6b36f5bb90..9a84a3b493 100644
--- a/apps/editor/app/test-holtmont/page.tsx
+++ b/apps/editor/app/test-holtmont/page.tsx
@@ -1,145 +1,78 @@
'use client'
-import { useEffect, useRef, useState } from 'react'
-
-// Minimal test scene: Site → Building → Level → 4 walls + slab + ceiling
-const TEST_SCENE = {
- nodes: {
- 'site-test': {
- object: 'node',
- id: 'site-test',
- type: 'site',
- parentId: null,
- visible: true,
- metadata: {},
- position: [0, 0, 0],
- rotation: 0,
- children: ['building-test'],
- },
- 'building-test': {
- object: 'node',
- id: 'building-test',
- type: 'building',
- parentId: 'site-test',
- visible: true,
- metadata: {},
- position: [0, 0, 0],
- rotation: 0,
- children: ['level-test'],
- },
- 'level-test': {
- object: 'node',
- id: 'level-test',
- type: 'level',
- parentId: 'building-test',
- visible: true,
- metadata: {},
- position: [0, 0, 0],
- rotation: 0,
- level: 0,
- children: ['wall-n', 'wall-s', 'wall-e', 'wall-w', 'slab-test', 'ceiling-test'],
- },
- 'wall-n': {
- object: 'node',
- id: 'wall-n',
- type: 'wall',
- parentId: 'level-test',
- visible: true,
- metadata: {},
- start: [-4, 0],
- end: [4, 0],
- height: 3,
- thickness: 0.2,
- children: [],
- },
- 'wall-s': {
- object: 'node',
- id: 'wall-s',
- type: 'wall',
- parentId: 'level-test',
- visible: true,
- metadata: {},
- start: [4, -6],
- end: [-4, -6],
- height: 3,
- thickness: 0.2,
- children: [],
- },
- 'wall-e': {
- object: 'node',
- id: 'wall-e',
- type: 'wall',
- parentId: 'level-test',
- visible: true,
- metadata: {},
- start: [4, 0],
- end: [4, -6],
- height: 3,
- thickness: 0.2,
- children: [],
- },
- 'wall-w': {
- object: 'node',
- id: 'wall-w',
- type: 'wall',
- parentId: 'level-test',
- visible: true,
- metadata: {},
- start: [-4, -6],
- end: [-4, 0],
- height: 3,
- thickness: 0.2,
- children: [],
- },
- 'slab-test': {
- object: 'node',
- id: 'slab-test',
- type: 'slab',
- parentId: 'level-test',
- visible: true,
- metadata: {},
- position: [0, 0, 0],
- rotation: 0,
- points: [
- [-4, 0],
- [4, 0],
- [4, -6],
- [-4, -6],
- ],
- thickness: 0.2,
- children: [],
- },
- 'ceiling-test': {
- object: 'node',
- id: 'ceiling-test',
- type: 'ceiling',
- parentId: 'level-test',
- visible: true,
- metadata: {},
- position: [0, 0, 0],
- rotation: 0,
- points: [
- [-4, 0],
- [4, 0],
- [4, -6],
- [-4, -6],
- ],
- thickness: 0.1,
- height: 3,
- children: [],
- },
- },
- rootNodeIds: ['site-test'],
- collections: {},
+/**
+ * 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 [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 appendLog = (msg: string) =>
- setLog((prev) => [`${new Date().toISOString().slice(11, 23)} ${msg}`, ...prev].slice(0, 50))
+ 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) => {
@@ -147,74 +80,103 @@ export default function TestHoltmontPage() {
if (!data?.type) return
if (data.type === 'PASCAL_READY') {
- appendLog('← PASCAL_READY received')
+ anotar('← PASCAL_READY', true)
setReady(true)
+ estado.current = { ...estado.current, ready: true }
} else if (data.type === 'HOLTMONT_3D_IMPORT_ACK') {
- appendLog('← HOLTMONT_3D_IMPORT_ACK received')
+ 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
- appendLog(
- `← HOLTMONT_3D_EXPORT received (${Object.keys(exportData?.nodes ?? {}).length} nodes)`,
- )
+ 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])
- const sendScene = () => {
- const iframe = iframeRef.current
- if (!iframe?.contentWindow) return
- appendLog('→ Sending HOLTMONT_3D_IMPORT...')
- iframe.contentWindow.postMessage({ type: 'HOLTMONT_3D_IMPORT', projectData: TEST_SCENE }, '*')
- }
-
- const sendAgain = () => {
- setReady(false)
- sendScene()
- }
+ // 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 (