diff --git a/AGENTS.md b/AGENTS.md index 229a38e..1726b56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,10 @@ npm run models:optimize # comprimir los .glb de public/assets/models - Nunca hacer commit, amend, push ni PR sin que lo pida explícitamente la persona usuaria. - Remoto: `origin` → https://github.com/jmoredev/PC-Simulator.git +- **Colaboración externa:** quien no trabaja aquí propone los cambios desde una + rama propia con un *pull request* hacia `main`, para que se revisen antes de + publicarse (las instrucciones para la persona colaboradora están en + `README.md`, sección «Cómo colaborar»). ## 4. Estructura de carpetas @@ -194,7 +198,13 @@ Reglas de layout: | 57 | **En práctica, cada fase empieza con su cartel** | Antes el cartel salía solo al completar una fase, así que quien arrancaba la partida o saltaba de fase no veía qué había que hacer. Ahora sale al **empezar** cada fase en práctica (también la primera, y también al reiniciar), se quita solo a los 3,6 s, y el estado guarda `lastAdvance` para poder decir «Fase completada» solo cuando se llegó completando y no cuando se saltó. En examen no se monta: el examen no da explicaciones. Cubierto en §16. | | 58 | **La pieza seleccionada se ve ampliada en una ventanita** (fase 1) | En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy pequeños. Al seleccionar una, una ventanita la enseña entera: el **PNG del conector** cuando existe (más nítido que el dibujo 3D: `ps2`, `usb`, `lan`, `hdmi`, `displayport`, `dvi`, `vga`) y, si no, una **vista 3D de cerca** con la misma `ComponentVisual` (drei no trae `View` en esta versión, así que es un mini lienzo). Va al centro-abajo —la captura enseñó que a la izquierda tapaba la lista de fases—, con `pointer-events: none` para no estorbar el arrastre, solo en la fase 1 y **sin el nombre en examen** (misma regla que la 56). Cubierto en §17. | | 59 | **La ventanita del zoom se monta una vez por fase, no por pieza** | Montarla condicionada a la pieza seleccionada la desmontaba **en cada colocación** de la fase 1 (18 por partida) y **cada montaje creaba un contexto WebGL nuevo**; el que el navegador se lleva por agotamiento es el más antiguo, o sea el de la **escena principal**, y la vista 3D dejaría de pintar. Ahora el panel (y su lienzo) viven toda la fase 1 y sin selección se **ocultan** con `zoom--oculto` (así el lienzo conserva su tamaño y su contexto); fuera de la fase 1 se desmontan, porque ahí no hacen falta. Hallazgo `R4-001` de la revisión nativa; cubierto en §17, donde el ciclo **seleccionar → colocar (que deselecciona) → seleccionar** se comprueba de verdad y un marcador sobre el nodo del lienzo detecta cualquier remontaje (falsado: con el montaje condicional fallan las cinco comprobaciones de ese ciclo). | -| 60 | **Los carteles de la fase 1 se reparten en 6 × 3, con las filas a 1,75 u** | Cada cartel acepta **un solo tipo** de pieza, así que la pieza nunca se coloca en el cartel equivocado: el defecto que reportó el alumnado era de **conteo**. `endDrag` cuenta fallo cuando **no** hay cartel correcto a `snapRadius` (0,6) y **sí** uno ajeno a `wrongRadius` (0,6), y con las filas a 0,95 u esa banda empezaba **a 0,35 u del centro**, o sea 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4): soltar cerca del borde contaba el vecino y **restaba puntos** (medido en examen: 100 → 97, 0 → 1 fallos). Con 1,75 u las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo deja la pieza sin colocar, sin coste. El peor hueco en pantalla entre dos tarjetas pasa de **23 px a 31 px**. Cuatro filas de cinco columnas no caben más separadas —a 1,25 u la de atrás queda a 0,1 u del borde de la mesa y a 1,3 se sale—, así que la forma pasa a 6 × 3 (18 celdas exactas, sin huecos). Todo medido con la proyección de la cámara, y el agarre de la pieza en el arnés quedó unificado en `COGER_PIEZA` (el monitor caía 0,15 u por encima del punto calculado y no se dejaba coger). Cubierto en §19, con la regla geométrica, el hueco en pantalla y la suelta real a 0,7 u; falsado: con 0,95 u fallan los cuatro. | +| 60 | **Los carteles de la fase 1 se reparten en 6 × 3, con las filas a 1,75 u** | Cada cartel acepta **un solo tipo** de pieza, así que la pieza nunca se coloca en el cartel equivocado: el defecto que reportó el alumnado era de **conteo**. `endDrag` cuenta fallo cuando **no** hay cartel correcto a `snapRadius` (0,6) y **sí** uno ajeno a `wrongRadius` (0,6), y lo cuenta **pasado `snapRadius`** —antes de eso la pieza coloca—, así que lo que decide es hasta dónde alcanza el vecino: con las filas a 0,95 u su banda llegaba a los **0,35 u** del centro, 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4), de modo que **cualquier tiro de más de 0,6 u caía dentro**, contaba el vecino y **restaba puntos** (medido en examen: 100 → 97, 0 → 1 fallos). Con las filas a 1,75 u (y la columna a 1,5 u, que es el mínimo) el vecino no entra hasta los **0,9 u**: los tiros entre 0,6 y 0,9 u ya no cuestan nada y solo dejan la pieza sin colocar, y pasado 0,9 u el vecino vuelve a contar — el margen sin coste pasa de 0,6 u a 0,9 u, **no a infinito**. El peor hueco en pantalla entre dos tarjetas pasa de **23 px a 31 px**. Cuatro filas de cinco columnas no caben más separadas —a 1,25 u la de atrás queda a 0,1 u del borde de la mesa y a 1,3 se sale—, así que la forma pasa a 6 × 3 (18 celdas exactas, sin huecos). Todo medido con la proyección de la cámara, y el agarre de la pieza en el arnés quedó unificado en `COGER_PIEZA` (el monitor caía 0,15 u por encima del punto calculado y no se dejaba coger). Cubierto en §19 —regla geométrica, hueco en pantalla, suelta real a 0,7 u y los márgenes de `snapRadius` y del vecino—; falsado: con 0,95 u fallan los seis. Las dos primeras redacciones de esta fila decían que el fallo empezaba a 0,35 u y que fallar de largo no costaba nada: lo corrigió la verificación independiente (V1-V5 MET, sin bloqueantes) y los checks que la acompañan lo dejan fijado. | +| 61 | **La rejilla de la fase 1 se deriva del número de piezas** | La fase 1 tiene una pieza por cada tipo de conector de la placa, así que su número cambia con la placa: **18** con ocho tipos y **19** en la 03, que trae uno más. La rejilla estaba fija en 6 columnas × 3 filas = 18 celdas, y los carteles en `ceil(n / 6)` filas, así que con 19 piezas el reparto se rompía por dos sitios a la vez: la pieza 19 repetía la celda de la 15 (una columna de más hacía que `floor(18 / 3)` se recortara a la última) y los carteles, repartidos en `ceil(19 / 6) = 4` filas de 1,75 u, dejaban **una fila entera fuera de la mesa** —medido: borde trasero en z = −6,2 con la mesa acabando en −4,9, y en pantalla a y = 111 px, por encima del borde lejano de la mesa (y = 154)—. Es el defecto que se reportó («los cables de audio, micrófono y E/S salen fuera de la mesa»): los carteles de esos cables eran algunos de los que quedaban flotando más allá del borde, y la pieza que compartía celda desaparecía al colocarla. Ahora `gridCols` calcula las columnas que hacen falta —hasta el suelo de separación: 0,9 u entre piezas (huella 0,75 + aire), 1,5 u entre carteles (`snapRadius` + `wrongRadius`) y ±4 / ±4,5 u de ancho— y avisa por consola si ni así caben, en vez de dejar dos piezas compartiendo sitio en silencio. Las filas y sus separaciones no cambian, así que con 18 piezas el reparto sale **idéntico** al de antes, y el arnés mide las dos formas: `traySeparation` e `identifyCards` corren en `motherboard-01` y `motherboard-03` —§11 y §19—. | +| 62 | **La pieza procedural se alinea midiendo en su espacio, no en el mundo** | Las piezas que dibuja el código (`Placeholder`) se ajustan con `Align`, que centraba la pieza midiendo su caja envolvente con `Box3.setFromObject` —en coordenadas de **mundo**—, así que el centrado incluía la propia colocación en la mesa y la dejaba en el **origen del mundo**: el centro de la mesa. Solo se notaba en las **cuatro piezas procedurales de la fase 1** (los tres jacks y el USB-C, los únicos sin PNG ni `.glb`): su celda quedaba vacía —el cartel con su nombre sí salía, así que el cable parecía estar fuera de la mesa— y al cogerlas con el ratón el fantasma aparecía en el centro de la mesa en vez de bajo el cursor, de modo que al soltarlas se perdían. Los `.glb` se ajustan dentro de `GltfModel` y los conectores con imagen son un plano que no se alinea, por eso llevaba tiempo ahí sin que lo cazara nadie: lo reportó el alumnado («algunos cables de los construidos por ti salen fuera de la mesa y desaparecen»). Ahora `localBox` mide cada malla por su matriz **relativa al grupo** (mundo → grupo) y el centrado vuelve a ser el correcto. Cubierto en §20 —barrido del lienzo, pieza por pieza: ninguna puede quedarse sin geometría en su celda—; falsado: sin el arreglo falla nombrando las cuatro piezas procedurales. | +| 63 | **La imagen del USB-C es una foto de Wikimedia Commons (CC BY-SA 4.0)** | El cable USB-C (señuelo de la fase 2) era el único conector sin PNG: se dibujaba con la forma procedural, justo la que destapó el defecto del `Align` (fila 62). Se parte de la foto de Santeri Viinamäki (`USB_Type-C_plug_20170626_crop.jpg`, Commons), se gira para que la clavija apunte hacia abajo como en el resto de imágenes y se le quita el fondo (PNG con transparencia; el fondo se detecta por crecimiento de región desde el borde, parando en el gradiente del contorno). Es la primera imagen del proyecto con licencia **BY-SA**: el archivo sigue bajo CC BY-SA 4.0 y hay que seguir citando autor y fuente. Está en `CREDITS.md` y en `src/data/credits.ts`, que se mantienen en paralelo (decisión 42). | +| 64 | **La portada se presenta como proyecto colaborativo y construido con Gentle-AI** | El repositorio es público, así que el `README.md` lleva el enlace al repositorio, el sello **«Built with Gentle-AI»** (el enlace y la imagen que pide el propio proyecto) y la sección **«Cómo colaborar»** con el flujo que pide su responsable: **nunca trabajar sobre `main`**, cada cambio en su rama (`feat/…`, `fix/…`, `docs/…`) y una **propuesta de cambio (pull request) hacia `main`** para que la revise antes de fusionarla —desde un *fork* si quien colabora no tiene permiso de escritura—. El desarrollo interno sigue en `develop` (sección 3). Es documentación pura, así que no hay prueba automática: se comprobaron los tres enlaces (repositorio, destino del sello e imagen del sello, los tres 200) y el snippet HTML renderizado en la portada. | +| 65 | **El proyecto se publica con licencia MIT (el código) y los recursos de terceros mantienen la suya** | El repositorio no tenía licencia, así que se añade `LICENSE` con **MIT**: permite a cualquiera usar, copiar y modificar el producto **citando el original** (conservar el aviso de copyright y el texto de la licencia), que es exactamente lo que se pidió y sin las obligaciones de una licencia copyleft. El titular es `jmoredev` (autoría de todos los commits del proyecto), con el año del primer commit (2026). `package.json` lo declara (`"license": "MIT"`) y el `README.md` lo explica. **Lo importante**: la licencia MIT cubre el **código** —lo que hay en el repositorio salvo los recursos de terceros—, y los **modelos 3D y las imágenes** NO se relicencian: cada archivo mantiene su licencia (CC BY, CC BY-SA 4.0 la foto del USB-C), con su autor y su fuente en `CREDITS.md`; si algún día se quisiera relicenciar todo, habría que sustituir los recursos de terceros por otros propios o compatibles. GitHub enseña la licencia en el lateral del repositorio cuando el archivo `LICENSE` está en la rama por defecto (`main`), así que llega al publicarse el PR. Es documentación y metadatos: no hay prueba automática, se comprobó el texto estándar de MIT sin modificar y que `npm run build`/`lint` siguen igual. | + ## 7. Pipeline de assets 3D @@ -388,6 +398,13 @@ disfrazaba de fallo de producto: siete comprobaciones «se quedó en identificac pasada entera, que dura unos 11 minutos): `ONLY=touchDrag,loadingBar node tests/run.mjs`, con los nombres de `SECTIONS` separados por comas. +**El socket de HMR de Vite no cuenta como error de la app**: el arnés ignora los +`console.error` de `WebSocket connection to ws://… failed` (una vez, en una +pasada de 20 minutos, el servidor rechazó una reconexión y la sección «sin +errores de página» salía en rojo con todos sus checks funcionales en verde). Si +el servidor se cae de verdad, las secciones fallan por sus propios checks y +`requireServer()` avisa antes de empezar. + ## 11. Despliegue (GitHub Pages) - **URL pública (tras activar Pages):** diff --git a/CREDITS.md b/CREDITS.md index d816860..7819baa 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,5 +1,9 @@ # Créditos y licencias +El **código** del simulador es [MIT](../LICENSE) (usar, copiar y modificar citando +el original). Lo de abajo son los **recursos de terceros**, que mantienen su propia +licencia. + Los **modelos 3D** y las **imágenes de los conectores** de este proyecto son obra de sus autores y se usan bajo la licencia que se indica en la tabla. Se pueden consultar también dentro del simulador, en **Menú → Créditos y licencias**. @@ -64,6 +68,11 @@ Todos los modelos 3D proceden de [Sketchfab](https://sketchfab.com) y son | `displayport.png` | Conector DisplayPort | | | | | | `dvi.png` | Conector DVI | | | | | | `vga.png` | Conector VGA | | | | | +| `usb-c.png` | Conector USB-C | Santeri Viinamäki | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:USB_Type-C_plug_20170626_crop.jpg) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | Recortada, girada y con el fondo quitado (PNG con transparencia) | + +> La imagen del USB-C es una **foto** de Wikimedia Commons: al ser **CC BY-SA**, +> el archivo `usb-c.png` (versión recortada y con fondo transparente) se +> distribuye con la misma licencia, y hay que seguir citando a su autor. ## Otras librerías diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5969e71 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jmoredev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4e1d4ec..2d6f143 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ didáctica para cada componente. ![modos](https://img.shields.io/badge/modos-práctica%20%7C%20examen-38bdf8) +**Repositorio:** — si quieres +proponer un cambio, mira [Cómo colaborar](#cómo-colaborar). + + + Built with Gentle-AI + + ## Versión publicada La versión online para el aula se publica desde la rama `main` en @@ -90,6 +97,9 @@ npm run models:optimize # comprime los .glb de public/assets/models - **`develop`** → rama de trabajo; **todo el desarrollo va aquí**. - Ramas cortas desde `develop` con prefijo `feat/`, `fix/` o `docs/`. +Si vas a proponer un cambio para que se revise, sigue [Cómo +colaborar](#cómo-colaborar): rama propia y *pull request* hacia `main`. + ```bash git clone https://github.com/jmoredev/PC-Simulator.git cd PC-Simulator @@ -100,6 +110,41 @@ npm install Las decisiones de diseño, la estructura de carpetas y las convenciones están documentadas en [`AGENTS.md`](AGENTS.md). +## Cómo colaborar + +El proyecto vive en y **todo cambio +pasa por una revisión** antes de llegar a la versión publicada: se propone en una +rama y se revisa en un *pull request* hacia `main`. + +1. **Trabaja siempre en una rama**, nunca directamente sobre `main`: + + ```bash + git clone https://github.com/jmoredev/PC-Simulator.git + cd PC-Simulator + git checkout -b feat/mi-cambio # o fix/..., docs/... + ``` + +2. **Haz el cambio y pruébalo** antes de proponerlo: + + ```bash + npm run build # compila sin errores + npm run lint # sin avisos + node tests/run.mjs # arnés de pruebas (necesita el servidor de dev levantado) + ``` + +3. **Abre un *pull request* hacia `main`**, no hacia otra rama, explicando qué + cambia y por qué. El responsable del proyecto lo revisa antes de fusionarlo; + hasta entonces el cambio vive en tu rama (y en `main` no entra nada sin + revisar). + +4. **Si no tienes permiso de escritura en el repositorio**, haz antes un *fork* + con el botón **Fork** de GitHub y abre el pull request desde tu copia a + `main` del repositorio original. + +Si el cambio añade o cambia algo visible (una fase, una pieza, un texto de la +interfaz), cuenta también qué hay que mirar para comprobarlo: es lo que se +revisa en el pull request. + ## Pruebas Con el servidor de desarrollo levantado: @@ -245,6 +290,17 @@ src/ y [@react-three/drei](https://github.com/pmndrs/drei) - [Zustand](https://zustand.docs.pmnd.rs/) para el estado +## Licencia + +El **código** de este proyecto (todo lo que hay en el repositorio salvo los +recursos de terceros) es **MIT**: cualquiera puede usarlo, copiarlo y modificarlo, +**citando el original** (conservar el aviso de copyright y la licencia, ver +[`LICENSE`](LICENSE)). + +Los **modelos 3D y las imágenes** de terceros **no** entran en esa licencia: cada +archivo mantiene la suya (CC BY, CC BY-SA…), con su autor y su fuente, en +[`CREDITS.md`](CREDITS.md). + ## Créditos y licencias Los modelos 3D y las imágenes de los conectores son obra de sus autores y se usan diff --git a/package.json b/package.json index fed45e4..4fc8cba 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", + "license": "MIT", "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/src/assets/connectors/README.md b/src/assets/connectors/README.md index c9a62a1..0fc51d2 100644 --- a/src/assets/connectors/README.md +++ b/src/assets/connectors/README.md @@ -33,3 +33,10 @@ El nombre debe ser el `kind` del conector. Estos son los que se usan: Si un PNG falta, ese conector se dibuja con la forma procedural de `src/three/Placeholder.tsx` y el simulador sigue funcionando. + +## Créditos + +El autor, la fuente, la licencia y los cambios de cada imagen están en +[`CREDITS.md`](../../CREDITS.md) y en `src/data/credits.ts`. Ojo con +`usb-c.png`: es una foto de Wikimedia Commons con licencia **CC BY-SA 4.0**, así +que hay que seguir citando a su autor y el archivo se queda con esa licencia. diff --git a/src/assets/connectors/usb-c.png b/src/assets/connectors/usb-c.png new file mode 100644 index 0000000..5fd7026 Binary files /dev/null and b/src/assets/connectors/usb-c.png differ diff --git a/src/data/components.ts b/src/data/components.ts index f05ec4a..22552ac 100644 --- a/src/data/components.ts +++ b/src/data/components.ts @@ -42,13 +42,6 @@ export const BOARD_MODEL = { rotation: ACTIVE_BOARD.rotation, } -function traySlot(stage: StageId, indexInStage: number): [number, number] { - const { cols, rows } = STAGE_BY_ID[stage].tray - const col = Math.floor(indexInStage / rows.length) - const row = indexInStage % rows.length - return [cols[Math.min(col, cols.length - 1)], rows[row]] -} - /** * Dimensión mayor con la que se dibuja una pieza en la mesa. Los modelos se * normalizan a `def.size` y luego se escalan para no pasar de 0,75 @@ -57,13 +50,89 @@ function traySlot(stage: StageId, indexInStage: number): [number, number] { */ export const TRAY_MAX_SPAN = 0.75 +/** Aire que se deja como mínimo entre dos piezas vecinas de la mesa. */ +const TRAY_MIN_GAP = 0.15 + /** Separación más pequeña entre dos líneas de una rejilla (columnas o filas). */ function gridGap(lines: number[]): number { return Math.min(...lines.slice(1).map((v, i) => Math.abs(v - lines[i]))) } -/** Aire que se deja como mínimo entre dos piezas vecinas de la mesa. */ -const TRAY_MIN_GAP = 0.15 +/** Cómo se reparten las columnas de una bandeja. */ +interface TrayGrid { + /** Nombre del reparto, para el aviso de consola. */ + name: string + /** Separación entre columnas mientras caben todas. */ + pitch: number + /** Anchura disponible para el reparto, que queda centrado en 0. */ + width: number + /** Suelo de la separación entre columnas: huella de una pieza más aire. */ + minPitch: number +} + +/** + * Columnas de una rejilla para `count` celdas repartidas en `rowCount` filas. + * + * Las filas las fija la bandeja y su separación está medida (en la fase 1: 2,1 u + * entre filas de piezas, para que la cámara oblicua no dibuje una justo detrás de + * otra, y 1,75 u entre carteles, para que el cartel de al lado no entre en la + * banda de fallo), así que el número de columnas sale del de celdas: las que hagan + * falta hasta tocar el suelo duro. + * + * Avisa por consola si ni con el suelo duro caben, porque a partir de ahí dos + * piezas compartirían celda en silencio: es el defecto que se midió con la placa + * de 19 piezas (los tres jacks de audio entre ellas). La pieza 19 repetía la celda + * de la 15 y los carteles, repartidos en `ceil(19 / 6) = 4` filas, ponían una fila + * entera **fuera de la mesa** (z = −6,2 con la mesa acabando en −4,9). + */ +function gridCols(rowCount: number, count: number, grid: TrayGrid): number[] { + const maxCols = Math.floor(grid.width / grid.minPitch) + 1 + const cols = Math.max(1, Math.min(maxCols, Math.ceil(count / rowCount))) + if (count > cols * rowCount) { + console.warn( + `bandeja: «${grid.name}» no da para ${count} celdas en ${cols}×${rowCount} ` + + `(caben ${cols * rowCount}); dos piezas compartirán sitio.`, + ) + } + const step = cols > 1 ? Math.min(grid.pitch, grid.width / (cols - 1)) : 0 + const span = step * (cols - 1) + return Array.from({ length: cols }, (_, i) => -span / 2 + i * step) +} + +/** + * Rejilla de las piezas de la fase 1: 6 columnas × 3 filas = 18 celdas para las 18 + * piezas de una placa con ocho tipos de conector, y una columna más (hasta 7, que + * dan 21) cuando la placa trae más. + * + * La anchura se queda en 8 u (±4) a propósito: más ancho sacaba piezas de la mesa + * visible (x ≈ 118 px) y las metía debajo de los paneles laterales. + */ +const PIECE_GRID: TrayGrid = { + name: 'piezas de la fase 1', + pitch: 1.6, + width: 8, + minPitch: TRAY_MAX_SPAN + TRAY_MIN_GAP, +} + +/** + * Columnas de la bandeja de una fase. La de la fase 1 se calcula la primera vez que + * hace falta, porque depende de cuántas piezas tenga la partida (`IDENTIFY_CELLS`, + * que se conoce unas líneas más abajo, cuando ya está montado el catálogo). + */ +let identifyCols: number[] | null = null +function trayCols(stage: StageId): number[] { + const { cols, rows } = STAGE_BY_ID[stage].tray + if (stage !== 'identify') return cols + return (identifyCols ??= gridCols(rows.length, IDENTIFY_CELLS, PIECE_GRID)) +} + +function traySlot(stage: StageId, indexInStage: number): [number, number] { + const { rows } = STAGE_BY_ID[stage].tray + const cols = trayCols(stage) + const col = Math.floor(indexInStage / rows.length) + const row = indexInStage % rows.length + return [cols[Math.min(col, cols.length - 1)], rows[row]] +} /** * Desplazamiento aleatorio máximo de una pieza dentro de su celda. @@ -77,8 +146,8 @@ const TRAY_MIN_GAP = 0.15 * la da el barajado, no el desplazamiento. */ export function trayJitter(stage: StageId): number { - const { cols, rows } = STAGE_BY_ID[stage].tray - const gap = Math.min(gridGap(cols), gridGap(rows)) + const { rows } = STAGE_BY_ID[stage].tray + const gap = Math.min(gridGap(trayCols(stage)), gridGap(rows)) // Una rejilla de una sola línea no tiene distancia que medir en ese eje // (`gridGap` devuelve infinito) y una de 1×1 no tiene parejas: sin este // guardia el desplazamiento saldría infinito y la pieza se iría de la mesa. @@ -495,6 +564,9 @@ function cableComponents(): RawComponent[] { const ALL_COMPONENTS: RawComponent[] = [...RAW, ...cableComponents()] +/** Celdas de la mesa de la fase 1: una por componente del catálogo. */ +const IDENTIFY_CELLS = ALL_COMPONENTS.length + export const COMPONENTS: ComponentDef[] = ALL_COMPONENTS.map((component, index) => { const indexInStage = stageCounters[component.stage]++ const variant = pickVariant(component) @@ -655,40 +727,55 @@ export function rotationPreviewDef(target: RotationTarget, rotation: Vec3): Comp } /** - * Rejilla de los carteles de la identificación: 6 columnas × 3 filas (18 carteles, - * uno por pieza, así que no sobra ninguna celda). + * Rejilla de los carteles de la identificación: 3 filas y las columnas que hagan + * falta, calculadas por `gridCols` (6 para 18 piezas, 7 para 19). * * La separación entre filas no es estética: en la fase 1 la colocación se resuelve * por cercanía al centro del cartel con `snapRadius` 0,6, y el fallo se cuenta * cuando un cartel AJENO está a `wrongRadius` (0,6) del punto donde se suelta - * (`endDrag` de `useGameStore`). Con las filas a 0,95 u —lo que había— la banda - * que cuenta el vecino empezaba a 0,35 u del centro, o sea **0,15 u más allá del - * borde visible de la tarjeta** (1,0 × 0,4): soltar cerca del borde contaba como - * fallo del cartel de al lado y restaba puntos. Ahora la fila mide 1,75 u, así que - * las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo - * significa que la pieza no se coloca, y el peor hueco en pantalla entre dos - * tarjetas pasa de 23 px a 31 px. + * (`endDrag` de `useGameStore`). El fallo se cuenta **pasado** `snapRadius` —antes de + * eso la pieza coloca—, así que lo que decide es hasta dónde alcanza el vecino: con + * las filas a 0,95 u, lo que había, su banda llegaba a los **0,35 u** del centro, + * 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4), y en la práctica + * **cualquier tiro de más de 0,6 u caía dentro**: soltar cerca del borde contaba + * como fallo del cartel de al lado y restaba puntos. Con la fila a 1,75 u (y la + * columna a 1,5, que es el mínimo) el vecino no entra hasta los 0,9 u: los tiros + * entre 0,6 y 0,9 u solo dejan la pieza sin colocar, y pasado ese punto el vecino + * vuelve a contar. El peor hueco en pantalla entre dos tarjetas pasa de 23 px a + * 31 px. * * Todo medido con la proyección de la cámara (`tests/`, sonda de la fase 1): cuatro * filas de 5 columnas no caben más separadas —con 1,25 la de atrás queda a 0,1 u - * del borde de la mesa y con 1,3 se sale— y seis columnas dejan 55 px de hueco - * horizontal con tres filas de 1,75. + * del borde de la mesa y con 1,3 se sale— y seis columnas dejan las tarjetas a + * 31 px en la misma fila (el peor hueco) y 55 px en la misma columna. */ -const PLATE_COLS = [-3.75, -2.25, -0.75, 0.75, 2.25, 3.75] -const PLATE_Z0 = -0.95 -const PLATE_DZ = 1.75 - -function plateSlot(index: number, rowCount: number): [number, number] { - const col = Math.min(Math.floor(index / rowCount), PLATE_COLS.length - 1) - const row = index % rowCount - return [PLATE_COLS[col], PLATE_Z0 - row * PLATE_DZ] +const CARD_ROWS = [-0.95, -2.7, -4.45] + +/** + * 1,5 u entre columnas es la separación mínima (con menos, el cartel de al lado + * entra en la banda de fallo) y la anchura disponible son 9 u (±4,5), que es donde + * caben las 7 columnas de una placa con más conectores. La forma que se elige es la + * que deja todo dentro de la mesa: repartir 19 carteles en una cuarta fila los saca + * de ella (la fila de atrás caía en z = −6,2 y la mesa acaba en −4,9). + */ +const CARD_GRID: TrayGrid = { + name: 'carteles de la fase 1', + pitch: 1.5, + width: 9, + minPitch: 1.5, +} +const CARD_COLS = gridCols(CARD_ROWS.length, IDENTIFY_CELLS, CARD_GRID) + +function plateSlot(index: number): [number, number] { + const rowCount = CARD_ROWS.length + const col = Math.min(Math.floor(index / rowCount), CARD_COLS.length - 1) + return [CARD_COLS[col], CARD_ROWS[index % rowCount]] } /** Carteles de la identificación: uno por pieza, con el nombre de su tipo. */ function identifyMounts(): MountPoint[] { - const rowCount = Math.ceil(COMPONENTS.length / PLATE_COLS.length) return COMPONENTS.map((def, index) => { - const [x, z] = plateSlot(index, rowCount) + const [x, z] = plateSlot(index) return { id: `identify_${index}`, stage: 'identify' as const, @@ -744,9 +831,8 @@ export function shuffleLayout(): void { // Los carteles se barajan aparte: si no, cada pieza caería sobre el suyo. const plates = MOUNTS_BY_STAGE.identify const plateOrder = shuffle(plates.length) - const rowCount = Math.ceil(plates.length / PLATE_COLS.length) plates.forEach((mount, i) => { - const [x, z] = plateSlot(plateOrder[i], rowCount) + const [x, z] = plateSlot(plateOrder[i]) mount.position = [x, 0.09, z] }) diff --git a/src/data/credits.ts b/src/data/credits.ts index a7224ed..18eee28 100644 --- a/src/data/credits.ts +++ b/src/data/credits.ts @@ -160,4 +160,13 @@ export const CREDITS: Credit[] = [ { file: 'connectors/displayport.png', what: 'Conector DisplayPort' }, { file: 'connectors/dvi.png', what: 'Conector DVI' }, { file: 'connectors/vga.png', what: 'Conector VGA' }, + { + file: 'connectors/usb-c.png', + what: 'Conector USB-C', + author: 'Santeri Viinamäki', + source: 'https://commons.wikimedia.org/wiki/File:USB_Type-C_plug_20170626_crop.jpg', + license: 'CC BY-SA 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/', + changes: 'Recortada, girada y con el fondo quitado (PNG con transparencia). Al ser CC BY-SA, el archivo sigue bajo la misma licencia.', + }, ] diff --git a/src/data/stages.ts b/src/data/stages.ts index d6edf29..aaf9869 100644 --- a/src/data/stages.ts +++ b/src/data/stages.ts @@ -39,6 +39,13 @@ export const STAGES: Stage[] = [ // lo que ocupan (0,75). El desplazamiento aleatorio se deriva de estas // separaciones (`trayJitter`), así que ensanchar la rejilla las separa. // + // Las columnas de esta fase NO son las de aquí abajo: la fase 1 tiene una + // pieza por cada tipo de conector de la placa, así que su número cambia de + // una placa a otra (18 piezas con ocho tipos, 19 si la placa trae uno más) y + // `components.ts` las deriva del catálogo (`trayCols`). Estos 6 valores son + // los que salen para 18 piezas; con 19 la rejilla pasa a 7 columnas y las + // piezas siguen dentro de ±4. Las filas SÍ son estas. + // // 6 columnas × 3 filas = 18 celdas para 18 piezas, con las filas a 2,1 u: dos // piezas de la misma columna —que la cámara oblicua dibuja una detrás de // otra— quedan a 1,4 u en profundidad (~110 px en pantalla) en vez de a 0,9. diff --git a/src/three/ComponentModel.tsx b/src/three/ComponentModel.tsx index e9a99b5..44775ac 100644 --- a/src/three/ComponentModel.tsx +++ b/src/three/ComponentModel.tsx @@ -10,6 +10,35 @@ import { Placeholder } from './Placeholder' const IDENTITY: Vec3 = [0, 0, 0] +/** + * Caja envolvente de un grupo **en su propio espacio**. + * + * `Box3.setFromObject` mide en coordenadas de MUNDO, así que incluye dónde está + * colocada la pieza: al centrar con ese resultado, la pieza acababa en el origen + * del mundo —el centro de la mesa— en vez de en su celda. Solo se notaba en las + * piezas procedurales (los cuatro conectores sin PNG ni .glb), que son las únicas + * que pasan por `Align` colocadas fuera del origen: los `.glb` se ajustan dentro + * de `GltfModel` y los conectores con imagen son un plano que no se alinea. + * + * Se mide multiplicando la caja de cada malla por su matriz relativa al grupo + * (mundo → grupo), que es lo que ignora a los padres. + */ +function localBox(group: THREE.Object3D): THREE.Box3 { + const box = new THREE.Box3() + const alGrupo = new THREE.Matrix4().copy(group.matrixWorld).invert() + const matriz = new THREE.Matrix4() + group.traverse((obj) => { + const mesh = obj as THREE.Mesh + if (!mesh.isMesh || !mesh.geometry) return + mesh.geometry.computeBoundingBox() + const bb = mesh.geometry.boundingBox + if (!bb) return + matriz.multiplyMatrices(alGrupo, mesh.matrixWorld) + box.union(bb.clone().applyMatrix4(matriz)) + }) + return box +} + /** * Gira un objeto ligero (placeholder) y lo reajusta: centrado en XZ y con la * base en y=0. Los modelos .glb se ajustan dentro de GltfModel, que memoriza @@ -22,8 +51,9 @@ function Align({ rotation, children }: { rotation: Vec3; children: ReactNode }) if (!group) return group.rotation.set(...rotation) group.position.set(0, 0, 0) - group.updateMatrixWorld(true) - const box = new THREE.Box3().setFromObject(group) + // Los padres primero: la caja local necesita la matriz de mundo del grupo. + group.updateWorldMatrix(true, true) + const box = localBox(group) const center = box.getCenter(new THREE.Vector3()) group.position.set(-center.x, -box.min.y, -center.z) group.updateMatrixWorld(true) diff --git a/tests/run.mjs b/tests/run.mjs index e2011cc..3f7191a 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -171,7 +171,15 @@ async function newPage(browser, url, seed) { const warns = [] page.on('pageerror', (e) => errors.push(e.message)) page.on('console', (m) => { - if (m.type() === 'error') errors.push('console: ' + m.text()) + // El socket de HMR de Vite no es un error de la aplicación: si el servidor de + // desarrollo se queda sin aceptar una reconexión —le pasó una vez en una + // pasada larga— el cliente registra un `console.error` que no tiene nada que + // ver con el simulador, y la sección entera salía en rojo por eso (los checks + // funcionales habían pasado). Si de verdad se cae el servidor, las secciones + // fallan igual por sus propios checks, y `requireServer()` avisa antes. + if (m.type() === 'error' && !/WebSocket connection to 'ws:\/\/[^']+' failed/.test(m.text())) { + errors.push('console: ' + m.text()) + } if (m.type() === 'warn') warns.push(m.text()) }) // seed: siembra localStorage ANTES de que arranque la app (el store la lee @@ -1228,7 +1236,7 @@ async function examNoHelp() { } /* ---------- 11. La mesa nunca apila dos piezas ---------- */ -async function traySeparation() { +async function traySeparation(board = 'motherboard-01') { // Dos piezas se solapan si su separación en LOS DOS ejes es menor que su // huella. Ninguna pieza dibujada supera 0,75 u en ningún eje (los modelos se // normalizan a `def.size` y luego se escalan para no pasar de 0,75; los cables @@ -1237,11 +1245,11 @@ async function traySeparation() { // siempre: antes era ±0,4 fijo y dos vecinas podían quedar a 0,1 u. const SPAN = 0.75 const RUNS = 200 - const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`) + const { page, errors } = await newPage(browser, `${BASE_URL}/?board=${board}`) const menu = await page .waitForSelector('button.mode-card', { timeout: 60000 }) .catch(() => null) - check('mesa: menú de modos visible (sonda de datos)', !!menu) + check(`mesa (${board}): menú de modos visible (sonda de datos)`, !!menu) if (!menu) { await page.close() return @@ -1298,7 +1306,7 @@ async function traySeparation() { for (const stage of ['identify', 'board', 'ports', 'peripherals']) { const r = result[stage] check( - `mesa: ninguna pieza se solapa en «${stage}» (${RUNS} barajados)`, + `mesa (${board}): ninguna pieza se solapa en «${stage}» (${RUNS} barajados)`, r.solapados === 0 && r.peor >= SPAN, `${r.piezas} piezas, separación mínima ${r.peor} u (mínimo ${SPAN}) y ${r.solapados} pares por debajo`, ) @@ -1307,7 +1315,7 @@ async function traySeparation() { // ninguna debe esconderse detrás de otra en la misma columna visual. const id = result.identify check( - 'mesa: en la fase 1 ninguna pieza se esconde en la misma columna visual', + `mesa (${board}): en la fase 1 ninguna pieza se esconde en la misma columna visual`, id.columnaCorta === 0 && id.peorColumna >= 1.3, `misma columna: separación mínima en profundidad ${id.peorColumna} u (mínimo 1.3) y ${id.columnaCorta} pares por debajo`, ) @@ -1317,12 +1325,12 @@ async function traySeparation() { // rejilla de esa fase siga dejando más aire del mínimo (0,9 en vez de 0,75), // que es la regresión que provocó el solape cuando la rejilla se estrechó. check( - 'mesa: la identificación mantiene su suelo de diseño (0,9 u)', + `mesa (${board}): la identificación mantiene su suelo de diseño (0,9 u)`, result.identify.peor >= 0.9, `separación mínima ${result.identify.peor} u (suelo de diseño 0,9; mínimo duro 0,75)`, ) - check('mesa: sin errores', errors.length === 0, errors.join(' | ')) + check(`mesa (${board}): sin errores`, errors.length === 0, errors.join(' | ')) await page.close() } @@ -2154,8 +2162,11 @@ async function stageBanner() { .catch(() => null) await menuCards[0].click() // práctica - await page.waitForSelector('.topbar', { timeout: 60000 }) - await wait(700) + // El cartel se quita solo a los 3,6 s, así que se ESPERA a que salga en vez de + // mirar a un tiempo fijo: con la máquina cargada (la pasada entera son 18 minutos + // de Chromium con render por software) se llegaba tarde a la lectura y el cartel + // ya no estaba —falso negativo medido en una pasada completa—. + await page.waitForSelector('.stage-banner', { timeout: 60000 }) const esperado = await page.evaluate(async () => { const m = await import('/src/data/stages.ts') return m.STAGES.map((s) => ({ id: s.id, titulo: s.title, texto: s.hint, corto: s.short })) @@ -2585,13 +2596,13 @@ async function examIdentifyHint() { * observable en examen —soltar 0,7 u hacia el cartel vecino más próximo— que es lo * que hacía perder puntos. */ -async function identifyCards() { - const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`) +async function identifyCards(board = 'motherboard-01') { + const { page, errors } = await newPage(browser, `${BASE_URL}/?board=${board}`) const cartas = await page .waitForSelector('button.mode-card', { timeout: 60000 }) .then(() => page.$$('button.mode-card')) .catch(() => []) - check('carteles: menú de modos visible', cartas.length >= 2, `${cartas.length}`) + check(`carteles (${board}): menú de modos visible`, cartas.length >= 2, `${cartas.length}`) if (cartas.length < 2) { await page.close() return @@ -2621,6 +2632,7 @@ async function identifyCards() { piezas: c.COMPONENTS_BY_STAGE.identify.length, snap: Math.max(...mounts.map((m) => m.snapRadius)), wrong: stage.drop.wrongRadius, + cardHalf: Math.max(w, d) / 2, minPlano, minX, minZ, @@ -2636,20 +2648,42 @@ async function identifyCards() { }) check( - 'carteles: hay uno por cada pieza', + `carteles (${board}): hay uno por cada pieza`, reparto.cuantos === reparto.piezas, `${reparto.cuantos} carteles para ${reparto.piezas} piezas`, ) check( - 'carteles: ningún cartel ajeno cabe en la banda de fallo del suyo', + `carteles (${board}): ningún cartel ajeno cabe en la banda de fallo del suyo`, reparto.minPlano >= reparto.snap + reparto.wrong, `separación mínima ${reparto.minPlano.toFixed(2)} u (X ${reparto.minX.toFixed(2)}, Z ${reparto.minZ.toFixed(2)}) frente a snapRadius + wrongRadius = ${(reparto.snap + reparto.wrong).toFixed(2)} u`, ) check( - 'carteles: la fila de atrás cabe en la mesa', + `carteles (${board}): la fila de atrás cabe en la mesa`, reparto.bordeTrasero >= reparto.mesaTrasera, `borde trasero ${reparto.bordeTrasero.toFixed(2)} u, la mesa llega a ${reparto.mesaTrasera.toFixed(2)} u`, ) + // El requisito de la persona usuaria: soltar SOBRE la tarjeta tiene que colocar + // siempre. Basta con que medio lado de la tarjeta quepa en `snapRadius`. + check( + `carteles (${board}): soltar sobre la propia tarjeta siempre coloca`, + reparto.cardHalf < reparto.snap, + `medio lado ${reparto.cardHalf.toFixed(2)} u frente a snapRadius ${reparto.snap.toFixed(2)} u`, + ) + // El fallo se cuenta PASADO `snapRadius` (antes coloca), así que lo que decide es + // hasta dónde llega el alcance del vecino: si `snapRadius` y ese alcance se + // solapan, no queda ningún tiro largo sin coste. + check( + `carteles (${board}): el vecino solo cuenta pasado snapRadius`, + reparto.minPlano - reparto.wrong > reparto.snap, + `el vecino entra en su banda a ${(reparto.minPlano - reparto.wrong).toFixed(2)} u del centro, más allá de snapRadius ${reparto.snap.toFixed(2)} u`, + ) + // El tiro que hace esta sección (0,7 u hacia el vecino más próximo) no puede + // alcanzar su banda: deja la pieza sin colocar y no cuesta nada. + check( + `carteles (${board}): el tiro de 0,7 u hacia el vecino no llega a su banda`, + reparto.minPlano >= reparto.snap + 0.7, + `separación mínima ${reparto.minPlano.toFixed(2)} u frente a ${(reparto.snap + 0.7).toFixed(2)} u`, + ) // Hueco en pantalla entre los rectángulos de dos tarjetas vecinas: es lo que se // ve («están muy pegadas»). El umbral es el medido con este reparto (31 px); con @@ -2670,22 +2704,22 @@ async function identifyCards() { } } check( - 'carteles: dos tarjetas vecinas no se pegan en pantalla', + `carteles (${board}): dos tarjetas vecinas no se pegan en pantalla`, hueco >= 28, `${Math.round(hueco)} px de hueco entre las dos más próximas; hacen falta 28`, ) - check('carteles: el reparto se mide sin errores de página', errors.length === 0, errors.join(' | ')) + check(`carteles (${board}): el reparto se mide sin errores de página`, errors.length === 0, errors.join(' | ')) await page.close() // El efecto que reportó el alumnado, en examen y con el ratón: se elige la pieza // cuyo cartel tiene al vecino MÁS próximo (con el reparto anterior son las filas a // 0,95 u) y se suelta 0,7 u hacia él, que es media tarjeta más allá de su borde. - const examen = await newPage(browser, `${BASE_URL}/?board=motherboard-01`) + const examen = await newPage(browser, `${BASE_URL}/?board=${board}`) const cartasExamen = await examen.page .waitForSelector('button.mode-card', { timeout: 60000 }) .then(() => examen.page.$$('button.mode-card')) .catch(() => []) - check('carteles: el examen arranca', cartasExamen.length >= 2, `${cartasExamen.length} tarjetas`) + check(`carteles (${board}): el examen arranca`, cartasExamen.length >= 2, `${cartasExamen.length} tarjetas`) if (cartasExamen.length < 2) { await examen.page.close() return @@ -2727,7 +2761,7 @@ async function identifyCards() { const [lejos] = await proyectaFase1(examen.page, [[cerca.x, objetivo.cartel[1], cerca.z]]) const [encima] = await proyectaFase1(examen.page, [objetivo.cartel]) check( - 'carteles: la pieza, su cartel y el punto vecino se localizan en pantalla', + `carteles (${board}): la pieza, su cartel y el punto vecino se localizan en pantalla`, !!pieza && !!lejos && !!encima, `${objetivo.name}: pieza ${JSON.stringify(pieza)}, a 0,7 u ${JSON.stringify(lejos)} (vecino a ${objetivo.distancia.toFixed(2)} u)`, ) @@ -2784,22 +2818,22 @@ async function identifyCards() { const despues = await juego() const puntosDespues = await puntos() check( - 'carteles: la pieza se coge y se suelta a 0,7 u de su cartel', + `carteles (${board}): la pieza se coge y se suelta a 0,7 u de su cartel`, soltado, `${objetivo.name}`, ) check( - 'carteles: soltar pasado el borde no cuenta el cartel de al lado', + `carteles (${board}): soltar pasado el borde no cuenta el cartel de al lado`, despues.errors === antes.errors && despues.filas === antes.filas, `fallos ${antes.errors} → ${despues.errors}, filas del resumen ${antes.filas} → ${despues.filas}`, ) check( - 'carteles: soltar pasado el borde no resta puntos', + `carteles (${board}): soltar pasado el borde no resta puntos`, puntosDespues === puntosAntes, `${puntosAntes} → ${puntosDespues}`, ) check( - 'carteles: la pieza no se coloca en el cartel de al lado', + `carteles (${board}): la pieza no se coloca en el cartel de al lado`, despues.colocadas === antes.colocadas, `${antes.colocadas} → ${despues.colocadas} colocadas`, ) @@ -2810,23 +2844,140 @@ async function identifyCards() { const colocado = await arrastra(encima) const final = await juego() check( - 'carteles: sobre su propio cartel la pieza se coloca', + `carteles (${board}): sobre su propio cartel la pieza se coloca`, colocado && final.colocadas === despues.colocadas + 1, `${despues.colocadas} → ${final.colocadas} colocadas`, ) check( - 'carteles: colocar bien no cuesta ningún fallo', + `carteles (${board}): colocar bien no cuesta ningún fallo`, final.errors === despues.errors, `${despues.errors} → ${final.errors}`, ) - check('carteles: sin errores de página', examen.errors.length === 0, examen.errors.join(' | ')) + check(`carteles (${board}): sin errores de página`, examen.errors.length === 0, examen.errors.join(' | ')) await examen.page.close() } +/* ---------- 20. Cada pieza de la fase 1 se dibuja en la celda que dice su dato ---------- */ +/** + * El fallback procedural (`Placeholder`) alineaba la pieza midiendo su caja + * envolvente en coordenadas de MUNDO (`Box3.setFromObject`), así que el centrado + * incluía la propia colocación de la pieza en la mesa y la dejaba en el origen del + * mundo: el centro de la mesa. Solo se notaba en las piezas que dibuja el código + * (los tres jacks y el USB-C, que no tienen ni PNG ni .glb): su celda quedaba vacía + * y, al arrastrarlas, el fantasma salía en el centro de la mesa en vez de bajo el + * cursor. Ninguna prueba las cogía con el ratón, así que pasaba desapercibido. + * + * La comprobación no mira el código: pregunta al lienzo por la geometría. Con + * `pointermove` sintéticos sobre el canvas —que es exactamente el evento que R3F + * escucha, y que no pasa por los paneles de la interfaz porque son DOM por encima— + * comprueba que debajo del punto donde la pieza DEBERÍA estar hay algo que responde + * al ratón (el cursor se pone en `grab`). Un `page.mouse.move` por punto costaría + * minutos; así se barren todas las piezas en un par de segundos. + */ +async function identifyDrawn(board = 'motherboard-01') { + const { page, errors } = await newPage(browser, `${BASE_URL}/?board=${board}`) + const cartas = await page + .waitForSelector('button.mode-card', { timeout: 60000 }) + .then(() => page.$$('button.mode-card')) + .catch(() => []) + check(`dibujo (${board}): menú de modos visible`, cartas.length >= 2, `${cartas.length}`) + if (cartas.length < 2) { + await page.close() + return + } + await cartas[0].click() + // Las piezas con .glb tardan: se espera a que la barra de carga se vaya (si no + // aparece, porque ya estaba todo en caché, sigue de largo). + await page + .waitForFunction(() => !document.querySelector('.loadingbar'), { timeout: 120000 }) + .catch(() => null) + await wait(800) + + const datos = await page.evaluate(async () => { + const c = await import('/src/data/components.ts') + const st = await import('/src/data/stages.ts') + const assets = await import('/src/data/assets.ts') + const stage = st.STAGES[0] + return { + total: c.COMPONENTS.length, + // Las que dibuja el código (sin PNG y sin .glb): son las que destaparon el + // defecto, así que se nombran en el detalle. + procedurales: c.COMPONENTS.filter( + (d) => !assets.connectorImageUrl(d.kind) && !assets.modelUrl(d), + ).map((d) => d.id), + mundos: c.COMPONENTS.map((d) => [ + d.identifyPos[0], + stage.tray.surfaceY + 0.05, + d.identifyPos[1], + ]), + nombres: c.COMPONENTS.map((d) => d.id), + } + }) + const pantallas = await proyectaFase1(page, datos.mundos) + + const sinDibujar = await page.evaluate( + async (puntos) => { + const canvas = document.querySelector('canvas') + const rect = canvas.getBoundingClientRect() + const hayPieza = (x, y) => { + canvas.dispatchEvent( + new PointerEvent('pointermove', { + clientX: rect.left + x, + clientY: rect.top + y, + bubbles: true, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + buttons: 0, + }), + ) + return document.body.style.cursor === 'grab' + } + // Nueve muestras alrededor del centro de la celda: el punto exacto puede caer + // en un hueco de la geometría (el latiguillo de un jack, el asa del monitor). + const offsets = [ + [0, 0], [12, 0], [-12, 0], [0, 12], [0, -12], + [12, 12], [-12, -12], [12, -12], [-12, 12], + ] + const fuera = [] + for (const p of puntos) { + const hay = offsets.some(([dx, dy]) => hayPieza(p.x + dx, p.y + dy)) + if (!hay) fuera.push(p.id) + } + return fuera + }, + pantallas.map((p, i) => ({ id: datos.nombres[i], x: p.x, y: p.y })), + ) + + check( + `dibujo (${board}): las ${datos.total} piezas de la fase 1 están dibujadas en su celda`, + sinDibujar.length === 0, + sinDibujar.length + ? `sin geometría en su celda: ${sinDibujar.join(', ')}` + : `${datos.procedurales.length} de ellas procedurales (${datos.procedurales.join(', ')})`, + ) + check(`dibujo (${board}): sin errores de página`, errors.length === 0, errors.join(' | ')) + await page.close() +} + /** * Secciones de la pasada, en orden. `ONLY=examSummary,loadingBar` acota la * ejecución a las nombradas (para falsar un cambio sin pagar la pasada entera). */ +/** + * Las dos formas que tiene hoy la fase 1: 18 piezas (una placa de ocho tipos de + * conector) y 19 (una más). La rejilla de esa fase se deriva del número de piezas + * —tanto la de las piezas como la de los carteles—, así que sus dos secciones se + * miden en las dos placas: con 19 piezas y la rejilla fija de 6 columnas, una fila + * entera de carteles caía fuera de la mesa y la pieza 19 pisaba la celda de la 15. + * La 02 tiene los mismos tipos que la 01, así que no aporta un reparto distinto. + */ +const PLACAS_FASE1 = ['motherboard-01', 'motherboard-03'] + +async function enLasDosPlacas(seccion) { + for (const board of PLACAS_FASE1) await seccion(board) +} + const SECTIONS = [ ['fullGame', fullGame], ['examGiveUpGuard', examGiveUpGuard], @@ -2837,7 +2988,7 @@ const SECTIONS = [ ['dragGhostVisible', dragGhostVisible], ['sidePanels', sidePanels], ['examNoHelp', examNoHelp], - ['traySeparation', traySeparation], + ['traySeparation', () => enLasDosPlacas(traySeparation)], ['examDrag', examDrag], ['examSummary', examSummary], ['touchDrag', touchDrag], @@ -2845,7 +2996,8 @@ const SECTIONS = [ ['stageBanner', stageBanner], ['componentZoom', componentZoom], ['examIdentifyHint', examIdentifyHint], - ['identifyCards', identifyCards], + ['identifyCards', () => enLasDosPlacas(identifyCards)], + ['identifyDrawn', () => enLasDosPlacas(identifyDrawn)], ] const ONLY = process.env.ONLY