Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions apps/editor/app/holtmont-bridge.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
if (isIframe) {
window.parent.postMessage(payload, '*')
}
}

function handleMessage(event: MessageEvent) {
const data = event.data as Record<string, unknown> | null
if (!data || data.type !== 'HOLTMONT_3D_IMPORT') return

let scene: ReturnType<typeof normalizeHoltmontScene>
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 })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import leaves editor selection stale

High Severity

HoltmontBridge applies an imported graph with setScene only and never runs applySceneGraphToEditor or syncEditorSelectionFromCurrentScene. Viewer buildingId and levelId keep pointing at the previous scene, so the floorplan resolves no walls, slabs, or ceilings and the imported project looks empty after a successful ACK.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 21db2b0. Configure here.


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<string, unknown>).__holtmontProbe = () => {
const nodes = useScene.getState().nodes
const resumen: Record<string, { total: number; conGeometria: number }> = {}
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
}
2 changes: 2 additions & 0 deletions apps/editor/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function RootLayout({
)}
</head>
<body className="font-sans">
<HoltmontBridge />
{children}
{process.env.NODE_ENV === 'development' && <Agentation />}
</body>
Expand Down
182 changes: 182 additions & 0 deletions apps/editor/app/test-holtmont/page.tsx
Original file line number Diff line number Diff line change
@@ -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=<nombre>` 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<string, unknown> | null
error: Record<string, unknown> | null
enviada: string | null
}

declare global {
interface Window {
__holtmontTest?: EstadoDePrueba
}
}

export default function TestHoltmontPage() {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [log, setLog] = useState<Registro[]>([])
const [ready, setReady] = useState(false)
const [escenas, setEscenas] = useState<string[]>([])
const [seleccionada, setSeleccionada] = useState<string>('')
const estado = useRef<EstadoDePrueba>({ 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<string, unknown> | 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<string, unknown>
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 (
<div style={{ display: 'flex', height: '100vh', fontFamily: 'monospace' }}>
<div
style={{
width: 320,
padding: 16,
borderRight: '1px solid #333',
background: '#111',
color: '#eee',
overflowY: 'auto',
}}
>
<h2 style={{ margin: '0 0 12px', fontSize: 14 }}>Puente Holtmont — banco de pruebas</h2>

<div style={{ fontSize: 11, color: ready ? '#4ade80' : '#facc15', marginBottom: 8 }}>
Editor: {ready ? '✓ listo' : 'esperando…'}
</div>

<select
onChange={(e) => setSeleccionada(e.target.value)}
style={{ width: '100%', marginBottom: 6, padding: 4, fontSize: 12 }}
value={seleccionada}
>
{escenas.map((nombre) => (
<option key={nombre} value={nombre}>
{nombre}
</option>
))}
</select>

<button
data-testid="enviar-escena"
disabled={!(ready && seleccionada)}
onClick={() => void enviarEscena(seleccionada)}
style={{
width: '100%',
padding: '6px 0',
cursor: ready ? 'pointer' : 'not-allowed',
background: ready ? '#16a34a' : '#374151',
color: '#fff',
border: 'none',
borderRadius: 4,
fontSize: 12,
marginBottom: 12,
}}
>
Enviar escena (IMPORT)
</button>

<div style={{ fontSize: 10, color: '#9ca3af', marginBottom: 4 }}>Bitácora:</div>
<div style={{ fontSize: 10, lineHeight: 1.6 }}>
{log.map((entrada) => (
<div
key={`${entrada.hora}-${entrada.texto}`}
style={{ color: entrada.entrante ? '#86efac' : '#93c5fd' }}
>
{entrada.hora} {entrada.texto}
</div>
))}
</div>
</div>

<iframe ref={iframeRef} src="/" style={{ flex: 1, border: 'none' }} title="Pascal Editor" />
</div>
)
}
Loading