{"_id":"@dotted-labs/ngx-products-3d","_rev":"7-fff03476534b4f13f324fd9038e64ba8","name":"@dotted-labs/ngx-products-3d","dist-tags":{"latest":"0.3.2"},"versions":{"0.1.0":{"name":"@dotted-labs/ngx-products-3d","version":"0.1.0","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"license":"MIT","_id":"@dotted-labs/ngx-products-3d@0.1.0","maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"}],"homepage":"https://github.com/dotted-labs/ngx-products-3d","bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"dist":{"shasum":"22e9bc4513a553ab08300da0d76cb2a1f682c40d","tarball":"https://registry.npmjs.org/@dotted-labs/ngx-products-3d/-/ngx-products-3d-0.1.0.tgz","fileCount":6,"integrity":"sha512-vndD9bftfw29JVdX41YTHSOuFDkWrosG/O6cQLN1EgtksHeZIT7lwj4/3quDTRB+oFW4YMHjHsR+c4Fe2vPv2w==","signatures":[{"sig":"MEUCIAe/EJYNpev+YO77SGIPucbPPsojqnOmUtPav7ZebNm7AiEAlK6FI6YvF3eaqgd9vIfluPEav51mB8bcklKKXogNrts=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":31407},"type":"module","module":"fesm2022/dotted-labs-ngx-products-3d.mjs","exports":{".":{"types":"./types/dotted-labs-ngx-products-3d.d.ts","default":"./fesm2022/dotted-labs-ngx-products-3d.mjs"},"./package.json":{"default":"./package.json"}},"gitHead":"5927d46000a4f976848a8800ee7b93735c1ca159","typings":"types/dotted-labs-ngx-products-3d.d.ts","_npmUser":{"name":"luismdev","email":"lm.torresrivera@gmail.com"},"repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"_npmVersion":"10.8.2","description":"Productos 3D interactivos para Angular (Angular Three v4)","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","dependencies":{"tslib":"^2.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"peerDependencies":{"three":">=0.174.0","meshline":"^3.0.0","ngxtension":">=5.0.0","@angular/core":"^21.0.0","angular-three":"^4.0.0","@angular/common":"^21.0.0","angular-three-soba":"^4.0.0","angular-three-rapier":"^4.0.0","@dimforge/rapier3d-compat":">=0.14.0"},"_npmOperationalInternal":{"tmp":"tmp/ngx-products-3d_0.1.0_1783417777497_0.2926597723799307","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@dotted-labs/ngx-products-3d","version":"0.1.1","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"license":"MIT","_id":"@dotted-labs/ngx-products-3d@0.1.1","maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"}],"homepage":"https://github.com/dotted-labs/ngx-products-3d","bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"dist":{"shasum":"b12ab49f29dee16465bad92b780c49202b4982e2","tarball":"https://registry.npmjs.org/@dotted-labs/ngx-products-3d/-/ngx-products-3d-0.1.1.tgz","fileCount":6,"integrity":"sha512-Xkej2nUTj5ec6nyIKFqzG009qcf4Zt7wXIZem2aUyaheAvRKGq0Ztz/i6KWPBY+I4Z7RJZWWyZVQBQ4uUbGdaQ==","signatures":[{"sig":"MEUCIBNB4riyGYWOTFMsl76WD2ZElNmZJhpsIf/1TvDU+o4NAiEAnU1q1TNBQer8FvEGmn4JL7LpBc09VUGXXh9KdSOkwz4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":31410},"type":"module","_from":"file:dotted-labs-ngx-products-3d-0.1.1.tgz","module":"fesm2022/dotted-labs-ngx-products-3d.mjs","exports":{".":{"types":"./types/dotted-labs-ngx-products-3d.d.ts","default":"./fesm2022/dotted-labs-ngx-products-3d.mjs"},"./package.json":{"default":"./package.json"}},"typings":"types/dotted-labs-ngx-products-3d.d.ts","_npmUser":{"name":"luismdev","email":"lm.torresrivera@gmail.com"},"_resolved":"/tmp/c65353bd9cb4193e7f4dd22c294f7f3f/dotted-labs-ngx-products-3d-0.1.1.tgz","_integrity":"sha512-Xkej2nUTj5ec6nyIKFqzG009qcf4Zt7wXIZem2aUyaheAvRKGq0Ztz/i6KWPBY+I4Z7RJZWWyZVQBQ4uUbGdaQ==","repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"_npmVersion":"10.8.2","description":"Productos 3D interactivos para Angular (Angular Three v4)","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","dependencies":{"tslib":"^2.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"peerDependencies":{"three":">=0.174.0","meshline":"^3.0.0","ngxtension":">=5.0.0","@angular/core":"^21.0.0","angular-three":"^4.0.0","@angular/common":"^21.0.0","angular-three-soba":"^4.0.0","angular-three-rapier":"^4.0.0","@dimforge/rapier3d-compat":">=0.14.0"},"_npmOperationalInternal":{"tmp":"tmp/ngx-products-3d_0.1.1_1783418543050_0.6476655640122668","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@dotted-labs/ngx-products-3d","version":"0.2.0","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"license":"MIT","_id":"@dotted-labs/ngx-products-3d@0.2.0","maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"}],"homepage":"https://github.com/dotted-labs/ngx-products-3d","bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"dist":{"shasum":"3f4dcba0988ce2eabd3bb9328120dd7ba1ad55d9","tarball":"https://registry.npmjs.org/@dotted-labs/ngx-products-3d/-/ngx-products-3d-0.2.0.tgz","fileCount":6,"integrity":"sha512-HjljuOnZ+wyA/IreG8B23ZwTE4+Nv+KTQfBcOCIIidrnWbzixh8LLjW2kV2q2m1LimwMWYa5S+NdNpZ2s0B1ow==","signatures":[{"sig":"MEUCIQCXmCnW6R7jAalefiJnX/j8+U3ybD7bXg4UEXdqAQjATgIgfYoP1eu6pqRaSZKRlb2Mo9BCofDbnp1ZhmOIaMLh05I=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":184634},"type":"module","_from":"file:dotted-labs-ngx-products-3d-0.2.0.tgz","module":"fesm2022/dotted-labs-ngx-products-3d.mjs","exports":{".":{"types":"./types/dotted-labs-ngx-products-3d.d.ts","default":"./fesm2022/dotted-labs-ngx-products-3d.mjs"},"./package.json":{"default":"./package.json"}},"typings":"types/dotted-labs-ngx-products-3d.d.ts","_npmUser":{"name":"luismdev","email":"lm.torresrivera@gmail.com"},"_resolved":"/tmp/b6f8fbebc1b338dbb20d6bd812ba1366/dotted-labs-ngx-products-3d-0.2.0.tgz","_integrity":"sha512-HjljuOnZ+wyA/IreG8B23ZwTE4+Nv+KTQfBcOCIIidrnWbzixh8LLjW2kV2q2m1LimwMWYa5S+NdNpZ2s0B1ow==","repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"_npmVersion":"10.8.2","description":"Productos 3D interactivos para Angular (Angular Three v4)","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","dependencies":{"tslib":"^2.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"peerDependencies":{"three":">=0.174.0 <0.183.0","meshline":"^3.1.0","ngxtension":">=5.0.0","@angular/core":"^21.0.0","angular-three":"^4.0.0","@angular/common":"^21.0.0","angular-three-soba":"^4.0.0","angular-three-rapier":"^4.0.0","@dimforge/rapier3d-compat":">=0.14.0 <0.20.0"},"_npmOperationalInternal":{"tmp":"tmp/ngx-products-3d_0.2.0_1784646006677_0.5754516666920524","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@dotted-labs/ngx-products-3d","version":"0.2.1","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"license":"MIT","_id":"@dotted-labs/ngx-products-3d@0.2.1","maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"}],"homepage":"https://github.com/dotted-labs/ngx-products-3d","bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"dist":{"shasum":"5ff5a167e00a027bd56a3cf92418b9c7fe1929a3","tarball":"https://registry.npmjs.org/@dotted-labs/ngx-products-3d/-/ngx-products-3d-0.2.1.tgz","fileCount":6,"integrity":"sha512-hZUiaNWBtBw9eI4QVRundQ+mFlgJsQ5NvTYAQzMuXIeW4fJoqscIT2kn67HWpv6AfTkLc6EgTelvXFVg+IG0oA==","signatures":[{"sig":"MEYCIQD9y6wgSznILHa72WXLWAR6LJFGwRSwce1gQK98wpZaNAIhAOfMOMBmnOIMP1A58Iewjilh42qK2T28RhZWUQPEW9W2","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":194238},"type":"module","_from":"file:dotted-labs-ngx-products-3d-0.2.1.tgz","module":"fesm2022/dotted-labs-ngx-products-3d.mjs","exports":{".":{"types":"./types/dotted-labs-ngx-products-3d.d.ts","default":"./fesm2022/dotted-labs-ngx-products-3d.mjs"},"./package.json":{"default":"./package.json"}},"typings":"types/dotted-labs-ngx-products-3d.d.ts","_npmUser":{"name":"luismdev","email":"lm.torresrivera@gmail.com"},"_resolved":"/tmp/ade742a0a8924d0248e9a6d877c249b2/dotted-labs-ngx-products-3d-0.2.1.tgz","_integrity":"sha512-hZUiaNWBtBw9eI4QVRundQ+mFlgJsQ5NvTYAQzMuXIeW4fJoqscIT2kn67HWpv6AfTkLc6EgTelvXFVg+IG0oA==","repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"_npmVersion":"10.8.2","description":"Productos 3D interactivos para Angular (Angular Three v4)","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","dependencies":{"tslib":"^2.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"peerDependencies":{"three":">=0.174.0 <0.183.0","meshline":"^3.1.0","ngxtension":">=5.0.0","@angular/core":"^21.0.0","angular-three":"^4.0.0","@angular/common":"^21.0.0","angular-three-soba":"^4.0.0","angular-three-rapier":"^4.0.0","@dimforge/rapier3d-compat":">=0.14.0 <0.20.0"},"_npmOperationalInternal":{"tmp":"tmp/ngx-products-3d_0.2.1_1784648343422_0.8239511345422039","host":"s3://npm-registry-packages-npm-production"}},"0.3.1":{"name":"@dotted-labs/ngx-products-3d","version":"0.3.1","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"license":"MIT","_id":"@dotted-labs/ngx-products-3d@0.3.1","maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"},{"name":"gunsr","email":"sergio.gundin.rodriguez@gmail.com"}],"homepage":"https://github.com/dotted-labs/ngx-products-3d","bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"dist":{"shasum":"ced563282757625b69f21f4d06f0a0be17cc93fa","tarball":"https://registry.npmjs.org/@dotted-labs/ngx-products-3d/-/ngx-products-3d-0.3.1.tgz","fileCount":6,"integrity":"sha512-dOe+7QK8FDCi9wwnBAyUTOqEbBrGAT1n+1VxbLozmsZtO0jU8EkkyeIyGduqPahMGEknYdodHlwRG0NOr0RBuQ==","signatures":[{"sig":"MEUCIQCAE7l0LHeVkOrYKkSakV02ctsib2+/+HgPh3jzU+3B/gIgcBsaK+aDRgRGj3e3WqtWvKXHoEeX4/zF8Us/B3GUULo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIDa+znkOPYBI9cf9b+mJDwS+omNkRx8NEGTB4LZCSzHSAiBQHF/AKtw2opT4t5f1FwsbFddRV+1zkjUZYkf+wTjO0A==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@dotted-labs%2fngx-products-3d@0.3.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":428337},"type":"module","module":"fesm2022/dotted-labs-ngx-products-3d.mjs","exports":{".":{"types":"./types/dotted-labs-ngx-products-3d.d.ts","default":"./fesm2022/dotted-labs-ngx-products-3d.mjs"},"./package.json":{"default":"./package.json"}},"gitHead":"dc8aed4712cbe5c2c7160fff7f974f0bcfa80783","typings":"types/dotted-labs-ngx-products-3d.d.ts","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"4e71b1d1-1873-4735-836a-5e656ca7f96a"}},"repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"_npmVersion":"11.19.0","description":"Productos 3D interactivos para Angular (Angular Three v4)","directories":{},"sideEffects":false,"_nodeVersion":"24.21.0","dependencies":{"tslib":"^2.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"peerDependencies":{"three":">=0.174.0 <0.183.0","meshline":"^3.1.0","ngxtension":">=5.0.0","@angular/core":"^21.0.0","angular-three":"^4.0.0","@angular/common":"^21.0.0","angular-three-soba":"^4.0.0","angular-three-rapier":"^4.0.0","@dimforge/rapier3d-compat":">=0.14.0 <0.20.0"},"_npmOperationalInternal":{"tmp":"tmp/ngx-products-3d_0.3.1_1790686346074_0.6635135204486733","host":"s3://npm-registry-packages-npm-production"}},"0.3.2":{"_id":"@dotted-labs/ngx-products-3d@0.3.2","bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"dist":{"shasum":"d093e93b57ef38c87f9aa28f3c8446557891885e","tarball":"https://registry.npmjs.org/@dotted-labs/ngx-products-3d/-/ngx-products-3d-0.3.2.tgz","fileCount":6,"integrity":"sha512-xH+ds4z7wXKpAXSQl9Ok9c+6H23QMzbr321MgpczBqyd7dyhKwzxTZ51fTAsMkkx7leHyZIdq7rwaVLJU2UCcg==","signatures":[{"sig":"MEQCIDMnq9kJxacpKccOIyY3uYLWlyIBsjvY7UiJhGw+3i0xAiBwly4axoVQIs/GRCg81zLgaSKGJ+BZwR0YFDbc1Z1OJw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDAbsCFgMubdyFptKHdi32TxGz1OvBBDjoV5ZrCfZuCDAIhAK0U4A2tg9pyDDoHI7AhqOUcbHFSqemz1qi/nkoXCkxC"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@dotted-labs%2fngx-products-3d@0.3.2","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":480350},"name":"@dotted-labs/ngx-products-3d","type":"module","author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"module":"fesm2022/dotted-labs-ngx-products-3d.mjs","exports":{".":{"types":"./types/dotted-labs-ngx-products-3d.d.ts","default":"./fesm2022/dotted-labs-ngx-products-3d.mjs"},"./package.json":{"default":"./package.json"}},"gitHead":"c9573f8eb1c869e3d0051497f809ca0ae4074c9b","license":"MIT","typings":"types/dotted-labs-ngx-products-3d.d.ts","version":"0.3.2","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"4e71b1d1-1873-4735-836a-5e656ca7f96a"}},"homepage":"https://github.com/dotted-labs/ngx-products-3d","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"_npmVersion":"11.19.0","description":"Productos 3D interactivos para Angular (Angular Three v4)","directories":{},"maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"},{"name":"gunsr","email":"sergio.gundin.rodriguez@gmail.com"}],"sideEffects":false,"_nodeVersion":"24.21.0","dependencies":{"tslib":"^2.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"peerDependencies":{"three":">=0.174.0 <0.183.0","meshline":"^3.1.0","ngxtension":">=5.0.0","@angular/core":"^21.0.0","angular-three":"^4.0.0","@angular/common":"^21.0.0","angular-three-soba":"^4.0.0","angular-three-rapier":"^4.0.0","@dimforge/rapier3d-compat":">=0.14.0 <0.20.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/ngx-products-3d_0.3.2_1790788224232_0.9889898386087654"}}},"time":{"created":"2026-07-07T09:49:37.346Z","modified":"2026-09-30T17:10:24.807Z","0.1.0":"2026-07-07T09:49:37.650Z","0.1.1":"2026-07-07T10:02:23.205Z","0.2.0":"2026-07-21T15:00:06.827Z","0.2.1":"2026-07-21T15:39:03.576Z","0.3.1":"2026-09-29T12:52:26.161Z","0.3.2":"2026-09-30T17:10:24.420Z"},"bugs":{"url":"https://github.com/dotted-labs/ngx-products-3d/issues"},"author":{"url":"https://github.com/dotted-labs","name":"Dotted Labs"},"license":"MIT","homepage":"https://github.com/dotted-labs/ngx-products-3d","keywords":["angular","angular-three","threejs","rapier","3d","badge"],"repository":{"url":"git+https://github.com/dotted-labs/ngx-products-3d.git","type":"git"},"description":"Productos 3D interactivos para Angular (Angular Three v4)","maintainers":[{"name":"luismdev","email":"lm.torresrivera@gmail.com"},{"name":"gunsr","email":"sergio.gundin.rodriguez@gmail.com"}],"readme":"# @dotted-labs/ngx-products-3d\n\nProductos 3D interactivos para Angular, construidos sobre [Angular Three v4](https://angularthree.org).\n\nPrimer producto: **badge** — acreditación 3D de socio colgada de una correa (lanyard) con física\nRapier, arrastrable con el puntero. El frente de la tarjeta se genera en runtime (textura base por\ntier + nombre/número/tier del socio en Text3D) y todo el aspecto se controla por tema.\n\n- Angular ≥ 21, standalone, signals, zoneless-safe, `OnPush`.\n- SSR-safe: el canvas solo monta en browser (guard interno) y el consumo documentado es `@defer`.\n- La lib no empaqueta ningún asset: GLB, texturas y fuente los aporta la app consumidora por URL.\n\n> ### ⚠️ Vienes de 0.2.x: lee esto antes de actualizar\n>\n> **0.3.1 cambia el contrato del modelo GLB y no da ningún error de compilación al hacerlo.** Hasta\n> 0.2.1 el origen del GLB debía ser el punto de anclaje del clip; ahora es el **centro de la\n> tarjeta**. Un modelo hecho para 0.2.x carga igual pero renderiza **desplazado 1.45 unidades**.\n> Ver § [Contrato del modelo GLB](#contrato-del-modelo-glb) y el\n> [CHANGELOG](https://github.com/dotted-labs/ngx-products-3d/blob/main/CHANGELOG.md) para la\n> migración y para el resto de cambios incompatibles.\n>\n> **0.3.1 también cambia el contrato del arte del frente**: la cara frontal se rellena con una\n> imagen de ratio **32:45 con canal alfa**, no con una imagen cuadrada. Un asset pensado para 0.2.x\n> se sigue cargando y renderizando, pero sale **estirado** y la lib avisa por consola en dev. Ver\n> § [Contrato del asset frontal](#contrato-del-asset-frontal).\n\n## Instalación\n\n```bash\nnpm install @dotted-labs/ngx-products-3d angular-three angular-three-soba angular-three-rapier three @dimforge/rapier3d-compat meshline ngxtension\nnpm install -D @types/three\n```\n\n### Peer dependencies\n\nTodo lo 3D son `peerDependencies` (la lib solo depende de `tslib`):\n\n| Peer | Rango |\n| --- | --- |\n| `@angular/common` | `^21.0.0` |\n| `@angular/core` | `^21.0.0` |\n| `angular-three` | `^4.0.0` |\n| `angular-three-soba` | `^4.0.0` |\n| `angular-three-rapier` | `^4.0.0` |\n| `@dimforge/rapier3d-compat` | `>=0.14.0 <0.20.0` |\n| `three` | `>=0.174.0 <0.183.0` |\n| `meshline` | `^3.1.0` |\n| `ngxtension` | `>=5.0.0` |\n\n## Peso: por qué `@defer` es obligatorio\n\nEl badge arrastra **three.js + el WASM de Rapier** (física) además de soba y meshline: en torno a\n1.5 MB extra que NO deben entrar en el bundle inicial ni ejecutarse en el server. El contrato de\nconsumo es:\n\n1. **Ruta lazy** (`loadChildren`/`loadComponent`) con `provideNgtRenderer()` en los `providers` de\n   esa ruta — devuelve `EnvironmentProviders`, Angular no lo admite en providers de componente,\n   así que la lib no puede aportarlo.\n2. **`@defer (on viewport)` + `@placeholder`** alrededor de `<products-3d-badge>`: el placeholder\n   estático (imagen) se sirve en SSR/LCP y el canvas 3D solo se hidrata al entrar en viewport.\n3. El componente incluye un guard `isPlatformBrowser` como cinturón de seguridad — no sustituye\n   a `@defer`, solo evita que un render en server reviente.\n\nPreload opcional del modelo:\n`<link rel=\"preload\" as=\"fetch\" href=\"/assets/3d/membresia.glb\" crossorigin>`.\n\n## Quickstart\n\n### 1. Providers en la ruta lazy\n\n```ts\n// membership.routes.ts\nimport type { Routes } from '@angular/router';\nimport { provideNgtRenderer } from 'angular-three/dom';\nimport { provideProducts3d, provideProducts3dBadgeTheme } from '@dotted-labs/ngx-products-3d';\nimport { MembershipComponent } from './membership.component';\n\nexport const membershipRoutes: Routes = [\n\t{\n\t\tpath: '',\n\t\tproviders: [\n\t\t\tprovideNgtRenderer(),\n\t\t\tprovideProducts3d({ cardModelUrl: '/assets/3d/membresia.glb' }),\n\t\t\tprovideProducts3dBadgeTheme({\n\t\t\t\tbandTextureUrl: '/assets/3d/band.png',\n\t\t\t\t// Arte del frente por tier: ratio 32:45 con alfa (§ Contrato del asset frontal)\n\t\t\t\tbaseTextures: {\n\t\t\t\t\tgold: '/assets/3d/front-gold.webp',\n\t\t\t\t\tsilver: '/assets/3d/front-silver.webp',\n\t\t\t\t},\n\t\t\t\tdefaultBaseTextureUrl: '/assets/3d/front-default.webp',\n\t\t\t\t// Typeface JSON de three, .otf o .ttf: la lib detecta el formato por la extensión\n\t\t\t\tfontUrl: '/assets/3d/font.otf',\n\t\t\t\t// Opcional. Color del modelo: se ve por las zonas transparentes del arte y tiñe\n\t\t\t\t// clip/clamp. Aquí se pisa con un violeta; sin él, la lib usa BADGE_BASE_COLOR ('#111111')\n\t\t\t\tbaseColor: '#3b0764',\n\t\t\t\tcolors: {\n\t\t\t\t\tband: '#ffe3c2',\n\t\t\t\t\tclip: '#b45309',\n\t\t\t\t\ttext: '#2a1205',\n\t\t\t\t},\n\t\t\t\tmaterial: {\n\t\t\t\t\troughness: 0.25,\n\t\t\t\t},\n\t\t\t}),\n\t\t],\n\t\tcomponent: MembershipComponent,\n\t},\n];\n```\n\n```ts\n// app.routes.ts\nexport const appRoutes: Routes = [\n\t{\n\t\tpath: 'membership',\n\t\tloadChildren: () => import('./membership/membership.routes').then((m) => m.membershipRoutes),\n\t},\n];\n```\n\n### 2. Componente consumidor con `@defer`\n\n```ts\nimport { ChangeDetectionStrategy, Component, signal } from '@angular/core';\nimport { Products3dBadge, type BadgeMemberData } from '@dotted-labs/ngx-products-3d';\n\n@Component({\n\tselector: 'app-membership',\n\timports: [Products3dBadge],\n\ttemplate: `\n\t\t@defer (on viewport) {\n\t\t\t<products-3d-badge [member]=\"member()\" [debug]=\"false\" />\n\t\t} @placeholder {\n\t\t\t<img src=\"/assets/badge-static.webp\" alt=\"Tarjeta de socio\" />\n\t\t}\n\t`,\n\tchangeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class MembershipComponent {\n\tprotected readonly member = signal<BadgeMemberData>({\n\t\tname: 'Sergio',\n\t\tmemberNumber: '0042',\n\t\ttier: 'gold',\n\t});\n}\n```\n\n`member` es un signal: mutarlo (form, API…) actualiza el frente de la tarjeta en vivo, sin\nrecrear el canvas.\n\n### 3. Scheduler de detección de cambios (imprescindible)\n\nEl badge carga geometría, texturas y el WASM de Rapier de forma asíncrona y todo su contenido\nvisible vive detrás de `@if` de recursos. La app consumidora DEBE tener un scheduler de CD que\nreaccione a signals:\n\n```ts\n// app.config.ts — apps zoneless (sin zone.js)\nimport { provideZonelessChangeDetection } from '@angular/core';\n\nexport const appConfig: ApplicationConfig = {\n\tproviders: [provideZonelessChangeDetection() /* … */],\n};\n```\n\nEn apps con zone.js, `provideZoneChangeDetection()` + zone.js cumple el mismo papel. Sin\nscheduler, la escena no se pinta hasta que un evento del DOM fuerza un ciclo de CD.\n\n## API\n\n### `<products-3d-badge>` (`Products3dBadge`)\n\nWrapper todo-en-uno: canvas + iluminación (ambient + environment con lightformers) + mundo\nfísico + escena del badge.\n\n| Input | Tipo | Requerido | Default | Descripción |\n| --- | --- | --- | --- | --- |\n| `member` | `BadgeMemberData` | sí | — | Datos del socio renderizados en la tarjeta |\n| `theme` | `Products3dBadgeTheme` | no | token del provider | Tema visual. Ver gotcha abajo |\n| `debug` | `boolean` | no | `false` | Wireframes de colliders/joints de la física |\n| `camera` | `Products3dBadgeCamera` | no | `{ fov: 25, distance: 13 }` (`BADGE_CAMERA`) | `fov` vertical (grados) y distancia de la cámara al badge. Para agrandar la tarjeta sin agrandar el contenedor. Ver § [Cámara](#cámara-tarjeta-más-grande-sin-agrandar-el-contenedor) |\n\n| Output | Tipo | Descripción |\n| --- | --- | --- |\n| `ready` | `void` | El badge terminó de cargar y se soltó (ver «Arranque» abajo). Se emite **una sola vez** |\n\n> **Gotcha del tema**: el input `[theme]` **PISA por completo** al tema registrado con\n> `provideProducts3dBadgeTheme()` — no hay merge campo a campo entre ambos. Si pasas `[theme]`,\n> pasa el tema COMPLETO. La resolución es: `input ?? token ?? Error`.\n\n#### Arranque: carga y caída\n\nMientras el badge carga **no se ve nada de él** (ni correa ni tarjeta) y su física está congelada.\nEspera a que terminen **todos** sus recursos: el WASM de Rapier, el GLB (`cardModelUrl`), la textura\nde la correa (`bandTextureUrl`), la textura base del tier y la fuente (con sus textos ya\nconstruidos). Después precompila los shaders (`WebGLRenderer.compileAsync`) para que el primer\nframe no dé un tirón, y entonces lo suelta: la cadena y la tarjeta **caen desde fuera del viewport\npor arriba** con física real y llegan con un poco de balanceo. En ese momento se emite `(ready)`.\n\n- **Un recurso en error cuenta como terminado**: el badge cae igual con el fallback de siempre\n  (correa de color plano, sin tarjeta, sin textos) y el aviso dev de cada recurso. Excepción: un\n  `fontUrl` de typeface JSON que no carga no se detecta como error (lo carga soba por dentro), así\n  que ese caso se resuelve por el tope de abajo.\n- **Tope**: si a los `BADGE_LOADING.timeoutMs` (10 s) algo sigue sin terminar, se suelta con lo que\n  haya y se avisa en dev (`[ngx-products-3d]`, con lo que faltaba).\n- **Solo al arrancar**: cambiar `theme` o `member` después (otra textura, otra fuente) se aplica en\n  sitio, sin volver a esconder ni congelar nada, y `(ready)` no se repite.\n- **`prefers-reduced-motion: reduce`**: sin caída; cuando todo ha cargado, el badge aparece\n  directamente colgando en su pose de reposo.\n- Pose de reposo: `BADGE_LAYOUT`, **centrada** en x = 0 (el eje de la cámara). Pose de salida:\n  `badgeDropLayout()`, derivada del frustum de la cámara **activa** (la del input `camera`, o\n  `BADGE_CAMERA` sin él) para que la tarjeta entera (clip incluido) quede por encima del borde\n  superior con `BADGE_DROP.frustumMargin` de holgura; `BADGE_DROP.lateralOffset` es el desplazamiento\n  lateral que le da el balanceo.\n\n```html\n<products-3d-badge [member]=\"member()\" (ready)=\"onBadgeReady()\" />\n```\n\n#### Cámara: tarjeta más grande sin agrandar el contenedor\n\nDesde 0.3.2 el badge cuelga **centrado** en su contenedor (hasta 0.3.1 lo hacía desplazado a la\nderecha): si tu layout compensaba ese descentrado, o el alto del contenedor estaba pensado para él,\nrevísalo. Lo que ocupa la tarjeta en pantalla lo decide la cámara, que se configura con el input\nopcional `camera`:\n\n```ts\nexport interface Products3dBadgeCamera {\n\t/** Campo de visión VERTICAL, en grados, en [1, 120). Default 25 */\n\tfov?: number;\n\t/** Distancia de la cámara al plano del badge, en unidades de mundo (> 0). Default 13 */\n\tdistance?: number;\n}\n```\n\n```html\n<!-- Tarjeta ≈ 1.4 veces más grande en el mismo contenedor: cámara más cerca -->\n<products-3d-badge [member]=\"member()\" [camera]=\"{ distance: 9 }\" />\n```\n\n- **Menos `distance` o menos `fov` = tarjeta más grande.** Lo que se ve del plano del badge mide\n  `2 · distance · tan(fov/2)` de alto, así que el tamaño de la tarjeta en pantalla es inversamente\n  proporcional a ese valor (13 · tan(12.5°) ≈ 2.88 con la cámara por defecto).\n- **Mejor `distance` que `fov`**: meshline dibuja la correa con un ancho proporcional a\n  `tan(fov/2)`, así que con menos `fov` la tarjeta crece pero la correa se **afina** respecto a ella\n  (a 20°, un 20% más estrecha). Con `distance` la proporción correa/tarjeta no cambia. El teselado de\n  la correa se recalcula solo con el `fov` que sea (`bandRepeatFor(aspect, fov)`): el arte no se\n  estira en ningún caso.\n- **Cada campo es independiente**: el que no pases toma su default (`BADGE_CAMERA_DEFAULTS`). La\n  cámara siempre está en x = 0, y = 0 (no se expone moverla de lado: descentraría el badge).\n- **Valores inválidos** (no numéricos, no finitos, `distance` ≤ 0, o `fov` fuera de `[1, 120)` =\n  `BADGE_CAMERA_LIMITS.minFov` / `maxFov`) caen a su default con un aviso dev `[ngx-products-3d]`.\n  Nunca lanzan ni dejan `NaN` ni infinitos en la cámara (un `fov` positivo pero minúsculo también se\n  rechaza: con él la proyección degenera).\n- **El anclaje de la correa nunca se ve.** La correa cuelga de un punto fijo en y = 4 que tiene que\n  quedar por encima del viewport, o se vería la correa acabar en el aire. Si la cámara pedida lo\n  enseñaría (más lejos o más abierta de la cuenta), la lib **acorta `distance`** hasta el máximo que\n  lo deja fuera, y avisa en dev. La holgura exigida es el medio ancho de la correa (que crece con el\n  `fov`: al inclinarse, la esquina de su extremo sube hasta ahí) más `BADGE_CAMERA_LIMITS.anchorMargin`\n  (0.15): ≈ 0.26 con 25°, ≈ 0.44 con 60°. Con 25°\n  ese máximo es ≈ 16.9: la tarjeta no puede hacerse más de un ~23% más pequeña que la de por\n  defecto. Para una tarjeta más pequeña, reduce el contenedor.\n- **Rango razonable**: `fov` entre 10° y 60°, y una cámara que deje caber la tarjeta en reposo, que\n  ocupa de y ≈ −1.41 (borde inferior) a y ≈ 1.28 (top del clamp): `distance · tan(fov/2)` ≳ 1.5 (con\n  25°, `distance` ≳ 6.8). En un contenedor más alto que ancho cuenta también el ancho:\n  `distance · tan(fov/2) · (ancho / alto del contenedor)` ≳ 0.8, el medio ancho de la tarjeta. Por\n  debajo la tarjeta se sale del encuadre; la lib no lo impide.\n- **En caliente**: cambiar `camera` después de montar actualiza la cámara del canvas y el teselado\n  de la correa en sitio, sin remontar nada y **sin repetir la caída** (la pose de salida se calcula\n  una sola vez, al arrancar).\n\n### `BadgeMemberData`\n\n| Campo | Tipo | Descripción |\n| --- | --- | --- |\n| `name` | `string` | Nombre completo mostrado en la tarjeta (texto principal; los nombres largos se reducen con escala **uniforme** para no desbordar, nunca comprimidos en un solo eje) |\n| `memberNumber` | `string` | Número de socio (se pinta con prefijo `#`) |\n| `tier` | `string` | Tier de membresía. Clave abierta: selecciona la textura base en `theme.baseTextures`; se pinta en mayúsculas como etiqueta |\n\n### `Products3dBadgeTheme`\n\n| Campo | Tipo | Requerido | Default | Para qué sirve |\n| --- | --- | --- | --- | --- |\n| `bandTextureUrl` | `string` | sí | — | Textura de la correa (lanyard). La lib aplica `RepeatWrapping` y **deriva** el teselado del aspecto real de la textura (`bandRepeatFor`), así que el arte solo tiene que ser tileable en X; alfa recomendada (§ [Requisitos de los assets del tema](#requisitos-de-los-assets-del-tema)). Si la carga falla: color plano + warn en dev |\n| `baseTextures` | `Record<string, string>` | sí (puede ir `{}`) | — | Arte del frente de la tarjeta por tier (key = `BadgeMemberData.tier`). **`{}` es válido**: con el mapa vacío todos los tiers caen en `defaultBaseTextureUrl`. Contrato del fichero: § [Contrato del asset frontal](#contrato-del-asset-frontal) |\n| `defaultBaseTextureUrl` | `string` | sí (**validado en runtime**) | — | Fallback obligatorio cuando el tier del socio no existe en `baseTextures`. Es el único campo de arte del frente realmente obligatorio |\n| `fontUrl` | `string` | sí (**validado en runtime**) | — | Fuente de los textos 3D del frente: typeface JSON de three, `.otf` o `.ttf`, con **autodetección por extensión** (§ [Requisitos de los assets del tema](#requisitos-de-los-assets-del-tema)) |\n| `baseColor` | `string` | no | `'#111111'` (`BADGE_BASE_COLOR`) | Color base del modelo. Ver el reparto abajo: **no** llega igual a todas las piezas |\n| `colors` | `object` | no | `{}` | Tintes opcionales |\n| `colors.band` | `string` | no | `'white'` | Tinte de la correa (se multiplica con la textura) |\n| `colors.text` | `string` | no | `'black'` | Color de los textos del socio. **Elígelo según tu arte**: el default negro sobre un frente oscuro queda al límite de legibilidad, y la lib no calcula contraste |\n| `colors.clip` | `string` | no | `baseColor` | Tinte del metal del clip/clamp; override específico que gana al `baseColor` global. La lib CLONA el material del GLB antes de teñir (el original nunca se muta) |\n| `material` | `Partial<BadgePhysicalMaterialOptions>` | no | defaults de la lib | Override parcial del `MeshPhysicalMaterial` de la tarjeta; se mergea campo a campo con los defaults |\n\n#### `baseColor`: dónde acaba cada parte del color\n\n`baseColor` es \"el color del modelo\", pero no se aplica igual en las tres piezas:\n\n| Pieza | Cómo lo usa | Override |\n| --- | --- | --- |\n| **Frente de la tarjeta** | Pinta el quad de fondo **opaco** de la escena que se renderiza a textura, detrás del arte del tier: es lo que se ve por las zonas **transparentes** del asset | — (no hay campo específico) |\n| **clip y clamp** | Tinte del material `metal` del GLB, resuelto como `colors.clip ?? baseColor ?? BADGE_BASE_COLOR` (`'#111111'`) | `colors.clip` |\n| **Canto y dorso de la tarjeta** | **Fuera de alcance**: comparten material y `map` con el frente, así que muestran lo que caiga en sus UV. `baseColor` no los controla | — |\n\n> `baseColor` **no** es el `color` del `MeshPhysicalMaterial` de la tarjeta, y no puede serlo: three\n> multiplica `map × color` en el shader y el `map` es la textura del frente, así que un color oscuro\n> ahí pintaría toda la tarjeta de negro. Por eso entra por la escena de la textura.\n\nDefaults del material de la tarjeta (`BadgePhysicalMaterialOptions`, exportados como\n`BADGE_MATERIAL_DEFAULTS`):\n\n| Campo | Default |\n| --- | --- |\n| `clearcoat` | `1` |\n| `clearcoatRoughness` | `0.15` |\n| `roughness` | `0.3` |\n| `metalness` | `0.5` |\n| `iridescence` | `0` |\n| `iridescenceIOR` | `1` |\n\n**Validación temprana**: si no hay tema resoluble (ni input ni provider), o al tema resuelto le\nfalta (o tiene vacío) `defaultBaseTextureUrl` o `fontUrl`, la lib lanza un\n`Error('[ngx-products-3d] badge: …')` accionable en el primer ciclo de CD — nunca falla en\nsilencio ni a mitad de render. El resto de fallos de carga (textura 404, GLB roto) degradan con\nun `console.warn` solo-dev: correa en color plano, escena sin tarjeta, o frente sin arte (liso del\ncolor base). Un asset frontal con el ratio equivocado también avisa y **sigue renderizando**\n(§ [Contrato del asset frontal](#contrato-del-asset-frontal)).\n\n### `provideProducts3d(config)` — `Products3dConfig`\n\n| Campo | Tipo | Requerido | Descripción |\n| --- | --- | --- | --- |\n| `cardModelUrl` | `string` | sí | URL del GLB de la tarjeta (geometría única para todos los temas; ver contrato abajo) |\n\nTambién se exportan los tokens `PRODUCTS_3D_CONFIG` y `PRODUCTS_3D_BADGE_THEME` por si prefieres\nproveerlos a mano.\n\n## Requisitos de los assets del tema\n\nLa lib **no empaqueta ningún asset**; los del playground del repo son solo demo. La app\nconsumidora aporta los suyos:\n\n- **Correa** (`bandTextureUrl`): cualquier formato que cargue `TextureLoader` de three (PNG, JPG,\n  WebP…).\n  - **Horizontal y tileable en X**: el eje largo de la imagen es la longitud de la correa y su alto\n    cubre el ancho. La lib no gira la textura (el shader de meshline ignora `rotation`/`offset` de\n    la textura), así que un arte vertical hay que girarlo en el propio fichero.\n  - **El aspecto es libre**: no hay un 4:1 fijo. La lib deriva el número de teselas del aspecto\n    real (ancho/alto) de la textura ya cargada con `bandRepeatFor(aspect)`:\n    `teselas = longitud de la correa / (aspecto × ancho de la correa)`, con longitud =\n    `BADGE_BAND.ropeJoints × BADGE_PHYSICS.segmentLength` (3 uds) y ancho =\n    `BADGE_BAND.lineWidth × tan(fov / 2)` con el `fov` de la cámara activa (≈ 0.2217 uds con el de\n    por defecto, 25°). Así el arte nunca se estira ni se comprime. Con la cámara por defecto, un\n    aspecto 4:1 da ≈ 3.38 teselas y uno 10:1, ≈ 1.35.\n  - **Un arte muy alargado da `repeat` < 1** (menos de una tesela): con la cámara por defecto, a\n    partir de un aspecto de ≈ 13.5:1 no cabe entero en la correa y se corta por el extremo (con más\n    `fov` el umbral baja, con menos sube). La solución es una tesela más corta en el asset, no\n    configuración.\n  - **Alfa recomendada** (PNG/WebP): el material de la correa declara `transparent`, así que las\n    zonas transparentes del arte no se pintan y dejan ver lo que haya detrás. Sin alfa la correa es\n    opaca en toda su superficie. `colors.band` tiñe el arte (se multiplica con él).\n  - Si la textura no expone dimensiones medibles, el teselado cae al de un arte de referencia 4:1\n    (`BADGE_BAND.referenceTextureAspect`), nunca a `NaN`.\n- **Arte del frente** (`baseTextures`, `defaultBaseTextureUrl`): tiene contrato propio, § siguiente.\n- **Fuente** (`fontUrl`): **typeface JSON** de three (formato de `FontLoader`), **`.otf`** o\n  **`.ttf`**. El formato se **autodetecta por la extensión** de la ruta (sin distinguir mayúsculas;\n  `?query` y `#hash` no cuentan): `.otf`/`.ttf` es fuente binaria y cualquier otra cosa se trata como\n  typeface JSON, que soba descarga tal cual, igual que siempre. No hay campo nuevo en el tema.\n  - La fuente binaria se convierte en el navegador con el `TTFLoader` de three (su `opentype`\n    embebido lee contornos TrueType y CFF). Entra por **`import()` dinámico**, así que tu bundler lo\n    deja en un chunk aparte que solo se descarga cuando el tema usa una `.otf`/`.ttf`: con typeface\n    JSON ese coste no se paga. Cero dependencias nuevas: viene con `three`, que ya es peer.\n  - El sentido de giro de los contornos (CFF frente a TrueType) lo decide la **cabecera** del fichero,\n    no la extensión, así que un `.otf` con contornos TrueType también sale bien.\n  - **WOFF/WOFF2 no se admiten**: conviértelas a `.otf`/`.ttf` (o a typeface JSON con\n    [facetype.js](https://gero3.github.io/facetype.js/)). Una `.otf`/`.ttf` que no carga o cuya\n    cabecera no es OTF/TTF degrada a **frente sin textos**, con un aviso dev `[ngx-products-3d]`.\n  - Un **typeface JSON** que no carga lo descarga y lo gestiona `NgtsText3D` de soba, no la lib: el\n    error de su recurso sale en consola como error de Angular (no como aviso `[ngx-products-3d]`),\n    los textos no llegan a construirse y el arranque espera al tope `BADGE_LOADING.timeoutMs`\n    (§ [Arranque](#arranque-carga-y-caída)).\n- **Modelo** (`cardModelUrl`): GLB que cumpla el contrato de § Contrato del modelo GLB.\n\n## Contrato del asset frontal\n\nEl arte que apunta `defaultBaseTextureUrl` (y cada entrada de `baseTextures`) **cubre la cara\nfrontal entera** de la tarjeta. La lib lo pinta sobre un quad opaco del `baseColor` del tema, así\nque las zonas transparentes del arte revelan ese color: **no hace falta recortar la silueta de la\ntarjeta a mano**.\n\n| Propiedad | Requisito |\n| --- | --- |\n| **Ratio** | **32:45 exacto** (= 1.6 : 2.25 ≈ 0.7111), el de la cara frontal del GLB. Es el único requisito geométrico |\n| **Tamaño en píxeles** | **Libre**. 1600 × 2250 px es solo la *densidad de referencia* (1000 px por unidad de mundo); 1024 × 1440 o 800 × 1125 valen igual mientras el ratio se mantenga |\n| **Canal alfa** | **Obligatorio si quieres que se vea el `baseColor`**. Sin alfa el arte tapa el fondo por completo (sigue siendo válido, solo que el `baseColor` no asoma por el frente) |\n| **Formato** | **No es un requisito**: cualquiera que cargue `TextureLoader` de three y soporte alfa (WebP y PNG son los habituales). Lo que importa es **alfa + ratio**, no la extensión |\n| **Espacio de color** | **sRGB**. La lib marca la textura como `SRGBColorSpace` al resolverla; exporta el asset en sRGB, no en Display P3 ni en lineal |\n| **Orientación** | **El borde superior de la imagen es el borde superior de la tarjeta**: tal como se ve en tu visor de imágenes, así se ve en la card. La lib ya concilia las dos convenciones de V que se cruzan aquí (UV de glTF vs. textura de un render target) |\n| **Márgenes** | Ninguno añadido por la lib: la imagen se mapea borde a borde sobre la cara. Deja tú el aire que necesites, y hueco abajo-izquierda para el nombre, el número y el tier (§ [Textos del socio](#textos-del-socio-badge_text_layout)) |\n\n**Ratio equivocado: la lib avisa, no rompe.** Si el ratio del asset se desvía más de un **1%** del\n32:45, la lib emite **un** `console.warn` con prefijo `[ngx-products-3d]`, la URL, el ratio esperado\ny el medido — y **sigue renderizando** el frente (estirado en esa misma proporción: el arreglo está\nen el asset, no en el código). El aviso es **solo de desarrollo** (`ngDevMode`): en un build de\nproducción no se emite. No se lanza ninguna excepción y no hay modo de \"fallar duro\" por ratio.\n\nOtros dos comportamientos degradados, por si los ves:\n\n- **URL que no carga** (404, CORS…): el frente se renderiza **liso del color base** con los textos\n  encima, más un aviso dev. Nunca escena en blanco.\n- **Canto y dorso de la tarjeta**: comparten material y `map` con el frente, así que muestran lo\n  que caiga en sus UV. Fuera del alcance de este contrato — el asset solo describe la cara frontal.\n\n## Textos del socio: `BADGE_TEXT_LAYOUT`\n\nDónde caen `name`, `memberNumber` y `tier` sobre el frente lo decide `BADGE_TEXT_LAYOUT`, un array\nde `BadgeTextSlot` exportado por la lib. Por defecto van **abajo-izquierda**, alineados a la\nizquierda (a bandera por la izquierda: los tres comparten la U `0.08`), con el tier justo encima:\n\n```ts\nexport interface BadgeTextSlot {\n\tfield: 'name' | 'memberNumber' | 'tier';\n\t/** Anclaje normalizado (0-1) sobre la cara. Origen ABAJO-IZQUIERDA. Ver la trampa de la V */\n\tanchor: [number, number];\n\t/** Borde del texto que se pega al anchor: 'right' = el texto crece hacia la izquierda */\n\talign: BadgeTextAlign; // 'left' | 'right' | 'center'\n\t/** Tamaño de la fuente, en unidades de mundo (la cara mide 1.6 × 2.25) */\n\tsize: number;\n\t/** Profundidad de extrusión del TextGeometry */\n\theight: number;\n\t/** Ancho máximo antes de reducir el texto con escala UNIFORME */\n\tmaxWidth: number;\n}\n\n// Valores por defecto de la lib\nexport const BADGE_TEXT_LAYOUT: BadgeTextSlot[] = [\n\t{ field: 'name', anchor: [0.08, 0.16], align: 'left', size: 0.09, height: 0.01, maxWidth: 0.65 },\n\t{\n\t\tfield: 'memberNumber',\n\t\tanchor: [0.08, 0.08],\n\t\talign: 'left',\n\t\tsize: 0.06,\n\t\theight: 0.01,\n\t\tmaxWidth: 0.4,\n\t},\n\t{ field: 'tier', anchor: [0.08, 0.24], align: 'left', size: 0.05, height: 0.01, maxWidth: 0.4 },\n];\n```\n\n> ### ⚠️ La trampa de la V\n>\n> **`anchor[1] = 0` es el borde INFERIOR de la tarjeta y `1` el superior.** Es **al revés** que los\n> UV del GLB, donde `v = 0` es el borde **superior** (convención glTF: `v = (1.125 − y) / 2.25`).\n>\n> El motivo es que el `anchor` se expresa sobre lo que encuadra la cámara de la escena de textura\n> —con +Y hacia arriba, como cualquier escena de three—, y quien concilia ambas convenciones al\n> pegar esa textura sobre la cara es la inversión de V del `map`\n> (`BADGE_TEXTURE.mapRepeat` / `mapOffset`), no el layout. Si copias un UV del GLB como `anchor`,\n> el texto sale **reflejado en vertical** respecto al arte.\n\nDetalles del encaje:\n\n- **Nunca hay escalado no uniforme.** Un texto más ancho que su `maxWidth` se reduce con escala\n  **uniforme** (`scale.x === scale.y === scale.z`); jamás se comprime solo en X. Un texto corto no\n  se agranda.\n- **El alineado lo resuelve la lib**, no `NgtsText3D`: el `TextGeometry` no tiene alineado y su\n  origen queda en el borde izquierdo de la línea base, así que la lib mide el bounding box **ya\n  escalado** y desplaza el mesh en X.\n- **La z no está en el slot**: es común a los tres textos y vive en `BADGE_TEXTURE.textLayerZ`\n  (capa por delante del arte, que a su vez va por delante del quad de `baseColor`).\n- **El color de los tres textos es `theme.colors.text`** (default negro). La lib no calcula\n  contraste contra el arte: **elígelo según tu arte**, o un texto oscuro sobre un frente oscuro\n  quedará al límite de la legibilidad.\n- `BADGE_TEXT_LAYOUT` es una constante **de referencia**: describe dónde caen los textos y sirve de\n  base si compones tu propia escena. La lib la lee de su propia config, no del tema.\n\n## Contrato del modelo GLB\n\nPara regenerar `membresia.glb` (Blender, exportador glTF, etc.) sin tocar código, el modelo debe\ncumplir exactamente:\n\n- **Nodos** (nombres exactos): `card` (mesh de la tarjeta), `clip` (gancho) y `clamp` (pinza).\n  `clip` y `clamp` pueden llevar transformación propia de nodo (la lib los monta como\n  `primitive` y la respeta); `card` va en identidad (ver **Origen**).\n- **Materiales** (nombres exactos): `base` asignado a `card`, `metal` asignado a `clip` y\n  `clamp`. En render la lib **sustituye** `base` por su propio `MeshPhysicalMaterial` (clearcoat +\n  textura dinámica del socio como `map`); `metal` se usa tal cual, o clonado y teñido si el tema\n  define `colors.clip`.\n- **Origen**: el origen del conjunto es el **centro de la tarjeta**, y el nodo `card` va con\n  transformación **identidad** (transforms aplicados en Blender). La lib monta el grupo visual\n  del modelo **sin offset** sobre el rigid body, porque el origen del body es el centro del\n  cuboid collider: origen del GLB = centro del collider = centro de la tarjeta.\n- **Escala/dimensiones**: unidades métricas, Y-up. La tarjeta mide **1.6 × 2.25 × 0.02**\n  unidades de mundo (bounding box del mesh de `card`: X`[-0.8, 0.8]`, Y`[-1.125, 1.125]`,\n  Z`[-0.01, 0.01]`). El collider físico es fijo en la lib (half-extents `[0.8, 1.125, 0.01]`):\n  si cambias las proporciones del modelo, el visual y la física dejarán de coincidir.\n- **Enganche de la correa**: son **dos puntos distintos**, y un GLB propio debe respetar ambos.\n  - **Anclaje físico**: el borde **superior del `clip`**, en **y ≈ 1.286** (el mesh del clip\n    abarca Y`[0.917, 1.286]`). Ahí ancla la lib el spherical joint del que cuelga la tarjeta\n    (`BADGE_PHYSICS.cardJointAnchor`).\n  - **Extremo visual de la correa**: el centro de la **ranura superior del `clamp`**, en\n    **y ≈ 1.479** (ranura pasante Y`[1.458, 1.500]`, ≈ 0.25 de ancho, más que los 0.2217 de la\n    correa con el `fov` por defecto). Ahí termina la correa, que se calcula cada frame con la pose renderizada de la\n    tarjeta (`BADGE_CARD_MODEL.bandAttachPoint`).\n  - Los dos valores por defecto asumen este contrato. Si tu `clip` o tu `clamp` están a otra\n    altura, ajusta la constante que corresponda.\n- **UVs**: el mesh de `card` necesita `TEXCOORD_0`; sobre esas UVs se proyecta la textura\n  dinámica del socio (RenderTexture). Sin UVs el frente sale liso. La cara frontal (+Z) debe\n  cubrir `[0, 1]²`. **Ojo con la V**: el unwrap de Blender y la textura de un render target usan\n  convenciones de V opuestas, así que la lib **invierte la V** por defecto\n  (`BADGE_TEXTURE.mapRepeat` `[1, -1]` + `mapOffset` `[0, 1]`), calibrado contra un export\n  estándar de Blender. Si tu modelo sale con el frente **espejado en vertical**, pon esas dos\n  constantes a `[1, 1]` y `[0, 0]`.\n- **Draco**: el asset de referencia va **sin comprimir** (~51 KB). El loader (soba\n  `gltfResource`) soporta Draco por defecto, pero el decoder se descarga en runtime desde el CDN\n  de Google (`gstatic.com`); si publicas un GLB comprimido y quieres self-hosting del decoder,\n  llama a `gltfResource.setDecoderPath('/draco/')` (de `angular-three-soba/loaders`) antes de\n  montar el badge.\n\n## Avanzado: canvas propio con `Products3dBadgeScene`\n\nSi quieres componer el badge con otros elementos 3D, la lib exporta la escena física\n(`<products-3d-badge-scene>`) para montarla en tu propio `<ngt-canvas>`. Necesitas:\n\n- `provideNgtRenderer()` en la ruta y `provideProducts3d({ cardModelUrl })` (la escena inyecta\n  `PRODUCTS_3D_CONFIG`).\n- Envolverla en `<ngtr-physics>` (la escena crea rigid bodies y joints Rapier) con\n  `updatePriority: BADGE_LOOP_PRIORITY.physicsStep` en sus `[options]`: la correa se construye con la\n  pose ya interpolada del frame, así que el paso físico tiene que ir antes que ella. Sin esa opción\n  el orden queda al azar y el extremo de la correa puede temblar respecto a la tarjeta.\n- Pasar un tema COMPLETO y válido por `[theme]` (la validación temprana vive en el wrapper\n  `Products3dBadge`, no en la escena).\n- Tu propia iluminación: el ambient + environment con lightformers pertenecen al wrapper.\n- Montarla solo en browser (`@defer`, igual que el wrapper).\n\n| Input de `Products3dBadgeScene` | Tipo | Requerido |\n| --- | --- | --- |\n| `member` | `BadgeMemberData` | sí |\n| `theme` | `Products3dBadgeTheme` | sí |\n| `debug` | `boolean` | no — reservado; en canvas propio el debug de física se activa en las `[options]` de `<ngtr-physics>` |\n| `camera` | `Products3dBadgeCamera` | no — `fov` y distancia de **tu** cámara; ver abajo |\n\n| Output de `Products3dBadgeScene` | Tipo | Descripción |\n| --- | --- | --- |\n| `ready` | `void` | Una sola vez, al soltar el badge con todo cargado (o al vencer el tope) |\n\nEl arranque (badge oculto y congelado hasta que todo carga, precompilación y caída) vive **en la\nescena**, así que también funciona en un canvas propio. No pausa el mundo de Rapier (`paused` de\n`<ngtr-physics>`): deshabilita solo los bodies del badge, así que no congela otros cuerpos que\ncompartan el mundo. Dos cosas a tener en cuenta:\n\n- La pose de salida y el teselado de la correa se derivan de la cámara que le pases por `camera`\n  (sin input, de `BADGE_CAMERA`). La escena **no mueve** la cámara de tu canvas: si usas otra,\n  créala centrada en x = 0, y = 0 a `distance` del plano z = 0 y pásale a la escena los mismos\n  `fov` y `distance`. Si no coinciden, la correa puede salir estirada y la tarjeta asomar en la pose\n  de salida (no se ve: la escena no la pinta hasta soltarla). La validación y el acotado de `distance`\n  del § [Cámara](#cámara-tarjeta-más-grande-sin-agrandar-el-contenedor) se aplican igual, pero solo a\n  lo que deriva la escena: tu cámara la gobiernas tú.\n- Precompila con la iluminación y el environment que haya en la escena en ese momento: monta tus\n  luces y tu `<ngts-environment>` junto al canvas, no después del badge.\n\n```ts\nimport { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core';\nimport { NgtCanvas } from 'angular-three/dom';\nimport { NgtrPhysics } from 'angular-three-rapier';\nimport {\n\tBADGE_CAMERA,\n\tBADGE_LOOP_PRIORITY,\n\tBADGE_PHYSICS,\n\tProducts3dBadgeScene,\n\ttype BadgeMemberData,\n\ttype Products3dBadgeTheme,\n} from '@dotted-labs/ngx-products-3d';\n\n@Component({\n\tselector: 'app-custom-badge-canvas',\n\ttemplate: `\n\t\t<ngt-canvas [camera]=\"camera\">\n\t\t\t<ng-template canvasContent>\n\t\t\t\t<ngt-ambient-light [intensity]=\"ambientIntensity\" />\n\t\t\t\t<ngtr-physics [options]=\"physicsOptions\">\n\t\t\t\t\t<ng-template>\n\t\t\t\t\t\t<products-3d-badge-scene [member]=\"member()\" [theme]=\"theme\" />\n\t\t\t\t\t</ng-template>\n\t\t\t\t</ngtr-physics>\n\t\t\t</ng-template>\n\t\t</ngt-canvas>\n\t`,\n\timports: [NgtCanvas, NgtrPhysics, Products3dBadgeScene],\n\tschemas: [CUSTOM_ELEMENTS_SCHEMA],\n\tchangeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class CustomBadgeCanvasComponent {\n\tprotected readonly camera = BADGE_CAMERA;\n\tprotected readonly ambientIntensity = Math.PI;\n\tprotected readonly physicsOptions = {\n\t\tgravity: BADGE_PHYSICS.gravity,\n\t\ttimeStep: BADGE_PHYSICS.timeStep,\n\t\tinterpolate: true,\n\t\tupdatePriority: BADGE_LOOP_PRIORITY.physicsStep,\n\t};\n\n\tprotected readonly member = signal<BadgeMemberData>({\n\t\tname: 'Sergio',\n\t\tmemberNumber: '0042',\n\t\ttier: 'gold',\n\t});\n\n\tprotected readonly theme: Products3dBadgeTheme = {\n\t\tbandTextureUrl: '/assets/3d/band.png',\n\t\t// baseTextures puede ir {}: todos los tiers caerían en defaultBaseTextureUrl\n\t\tbaseTextures: { gold: '/assets/3d/front-gold.webp' },\n\t\tdefaultBaseTextureUrl: '/assets/3d/front-default.webp',\n\t\tfontUrl: '/assets/3d/font.otf',\n\t\t// Opcional: override del default BADGE_BASE_COLOR ('#111111')\n\t\tbaseColor: '#3b0764',\n\t};\n}\n```\n\nLas constantes de configuración del badge (`BADGE_CAMERA`, `BADGE_PHYSICS`, `BADGE_LIGHTING`,\n`BADGE_MATERIAL_DEFAULTS`, `BADGE_BASE_COLOR`, `BADGE_FRONT_FACE`, `BADGE_TEXTURE`,\n`BADGE_TEXT_LAYOUT`, …) son parte de la API pública y sirven como valores de referencia\npara composiciones propias. Las del frente están todas derivadas del mismo rect: `BADGE_FRONT_FACE`\n(la cara de la tarjeta, 1.6 × 2.25) manda sobre el FBO (`BADGE_TEXTURE.width`/`height`), el frustum\nde su cámara ortográfica (`cameraFrustum`), los quads del fondo (`frontPlaneSize`) y el ratio que\nse le exige al asset (`assetAspect`). También se exporta `Products3dBadgeTexture` (la escena del\nfrente que se renderiza a textura), pensada para uso interno/avanzado dentro de un\n`NgtsRenderTexture`.\n\n## Licencia\n\nMIT\n","readmeFilename":"README.md"}