{"_id":"@coding-craftsmen-guild/model-showroom","name":"@coding-craftsmen-guild/model-showroom","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@coding-craftsmen-guild/model-showroom","version":"0.1.0","description":"Framework-free custom elements for configuring a 3D product model: swap per-surface materials on a GLB and let the host page own the layout.","type":"module","license":"MIT","main":"./dist/model-showroom.js","module":"./dist/model-showroom.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/model-showroom.js"},"./pipeline":{"types":"./dist/pipeline/index.d.ts","import":"./dist/model-showroom.pipeline.js"},"./package.json":"./package.json","./iife":{"types":"./dist/index.d.ts","import":"./dist/model-showroom.iife.js"}},"sideEffects":["./dist/model-showroom.js","./dist/model-showroom.iife.js"],"scripts":{"dev":"vite","build":"tsc --noEmit && vite build && vite build --mode pipeline","build:site":"vite build --config vite.site.config.ts","preview":"vite preview","preview:site":"vite preview --config vite.site.config.ts","test":"vitest run","test:watch":"vitest","typecheck":"tsc --noEmit","demo:assets":"node scripts/copy-demo-assets.mjs","spike":"node scripts/run-spike.mjs","check:demo":"node scripts/check-demo.mjs","check:dist":"node scripts/check-dist.mjs","shoot:catalogue":"node scripts/shoot-catalogue.mjs"},"dependencies":{"@google/model-viewer":"^4.3.1"},"devDependencies":{"@playwright/test":"^1.49.0","@types/node":"^22.20.1","@vitest/coverage-v8":"^3.2.7","happy-dom":"^15.11.0","typescript":"^5.7.0","vite":"^6.0.0","vite-plugin-dts":"^4.3.0","vitest":"^3.2.7"},"_id":"@coding-craftsmen-guild/model-showroom@0.1.0","gitHead":"3f10f827e3a68e24976da6f62ff8f6a0300067d9","_nodeVersion":"22.23.2","_npmVersion":"10.9.8","dist":{"integrity":"sha512-FxagvT0yNxy5WOrFHJZAkfZ1HxJZ1fjdaVunfIDQti/owNaJ4lg1Rp2uQ16RCZifsDrfChcrickymNfVXbPYoQ==","shasum":"27b078414f9e9f7f69fb166142d9e86a9ce787ed","tarball":"https://registry.npmjs.org/@coding-craftsmen-guild/model-showroom/-/model-showroom-0.1.0.tgz","fileCount":26,"unpackedSize":12691112,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIHxuxxantZKMugaHCGa+V4IAX+9MTR0q1G49bzTEgweaAiBDkWwefoQgIhioaRcb8BCugBRNR87HNliIAs74Ppa9/w=="}]},"_npmUser":{"name":"mbogunovic","email":"maksimbogunovic@gmail.com"},"directories":{},"maintainers":[{"name":"mbogunovic","email":"maksimbogunovic@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/model-showroom_0.1.0_1788349455056_0.16696392088867573"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-02T11:44:14.800Z","0.1.0":"2026-09-02T11:44:15.292Z","modified":"2026-09-02T11:44:15.552Z"},"maintainers":[{"name":"mbogunovic","email":"maksimbogunovic@gmail.com"}],"description":"Framework-free custom elements for configuring a 3D product model: swap per-surface materials on a GLB and let the host page own the layout.","license":"MIT","readme":"# model-showroom\n\nThree custom elements for configuring a 3D product model. Swap per-surface materials on\na GLB; the host page owns every pixel of layout.\n\n```html\n<script type=\"module\" src=\"model-showroom.js\"></script>\n\n<model-showroom config=\"fotelja-balvana.json\">\n\n  <model-showcase ar dimensions hint></model-showcase>\n\n  <aside>                                  <!-- your markup, your CSS -->\n    <h2>Fabric</h2>\n    <p>Choose a fabric for the body.</p>\n    <model-variant target=\"body\" metadata='{\"id\":\"amsterdam_57\",\"label\":\"57\",\"swatch\":\"#395B68\"}'>\n    </model-variant>\n  </aside>\n\n</model-showroom>\n```\n\n## Why three elements\n\nA single `<furniture-configurator>` would bake the panel layout into the library. Keeping\nthe viewer, the context and the swatches separate means the same code produces a 65/35\ndesktop split, a mobile bottom sheet, or a horizontal strip — with headings, ordering and\nspacing all written as ordinary host HTML.\n\n| Element | Role |\n|---|---|\n| `<model-showroom>` | Context. Owns config, selection, texture cache. Renders nothing (`display: contents`). |\n| `<model-showcase>` | The 3D viewport, plus loading state, AR button, interaction hint, dimensions. |\n| `<model-variant>` | One swatch: a square and a caption. |\n\nChildren find their showroom by DOM nesting. When nesting is impossible — a CMS template,\nseparately rendered regions — `for=\"showroom-id\"` links them explicitly.\n\n`<model-showcase src=\"chair.glb\">` on its own, with no showroom, is just a viewer.\n\n## Configuration\n\nA variant is self-contained: rendering a swatch and applying a material never need a\nfetch beyond the texture files.\n\n```jsonc\n{\n  \"id\": \"mercis_amsterdam_01\",\n  \"label\": \"Amsterdam 01\",\n  \"swatch\": \"#EFF0EB\",                        // a CSS colour, or an image URL\n  \"maps\": { \"baseColor\": \"shared_basecolor.png\",\n            \"normal\":    \"shared_normal.png\",\n            \"orm\":       \"shared_orm.png\" },  // every key optional\n  \"factors\": { \"metallic\": 0.0, \"roughness\": 0.858,\n               \"baseColor\": [0.921458, 0.92368, 0.886897, 1.0] },\n  \"tileSize\": { \"u\": 0.0256, \"v\": 0.0256 }    // metres per tile; a number if square\n}\n```\n\n`tileSize` and a surface's `tiling` may each be a number or `{ u, v }`. The UV repeat is\n`tiling / tileSize` per axis, so a fabric with a different weave scale — or a rectangular\none — drops onto UVs that were unwrapped for another.\n\n`factors.metallic` and `factors.roughness` have always been per-variant; what schema 4.0 added\nis that a *published family* may state them per colourway rather than only once for the whole\nfamily. `expandLibrary` merges the family's factors under the variant's, so a colourway that\nstates neither keeps the family's — which today is every published colourway, since every\nfamily is now one material. That merge is what makes the change free in both directions: the\nadapter emits nothing, the variant carries the family's pair, and the elements never learn\nthere was a distinction to make.\n\nGive it to an element as a `metadata` attribute (JSON) or a `.metadata` property\n(object), or let a `<model-showroom config=\"…\">` supply a whole `ShowroomConfig`.\n\nSurfaces join the glTF by **material name**, defaulting to `${target}_material`. Never by\nindex — index joins break the first time a surface is retired and leaves a gap.\n\n`MaterialLibrary` is an authoring convenience for families that share maps; `expandLibrary()`\nflattens it into plain variants. The elements only ever see the flattened form.\n\n## Styling\n\nShadow DOM with `::part()` and custom properties. The library ships a bare functional\nappearance and no layout opinions.\n\n```css\nmodel-showcase {\n  width: 100%; height: 70vh;\n  --ms-background: linear-gradient(180deg, #F6F7F7, #EEF4F8);\n}\nmodel-variant {\n  --ms-swatch-size: 57px;\n  --ms-swatch-outline-selected: 2px solid #212529;\n}\nmodel-showcase::part(ar-button) { border-radius: 0; }\n```\n\n`demo/index.html` is a complete worked example — the Radović configurator's design\nrebuilt on these elements, with nothing theme-related in the library. It reads\n`demo/public/catalogue.json`, whose entries are already `ShowroomConfig` objects, and\ninstalls them without an adapter; the family manifests are the one thing still converted,\nbecause theirs is a published format with a contract behind it. Being hand-authored, the\ncatalogue *can* drift from the GLBs beside it — silently, since a materialName the model\ndoes not carry lights no swatch and raises no error — which is why\n`../surface-separator/check-catalogue.mjs` exists. A picker switches between every asset in\nthe catalogue and `?model=<id>` makes any configuration shareable.\n\n## Events\n\n```js\nshowroom.addEventListener('change', (e) => {\n  e.detail; // { target, variant, selection }\n});\n```\n\nAlso `load` (a `config` URL resolved) and `error`. URL syncing is deliberately left to\nthe host: a component cannot own `history.replaceState` on a page it does not control.\nThe demo shows the four lines it takes.\n\n## Pipeline adapter\n\n`model-showroom/pipeline` converts [AIAssetPipeline][pipe] exports — a `*_config.json`\nplus material-family manifests — into a `ShowroomConfig`. It is a separate entry point with\nno three.js dependency, so upstream schema drift never reaches the elements.\n\n[pipe]: https://github.com/\n\nHeadings and descriptions come from an `authoring` map rather than the export, because\nthe pipeline has no such fields and, in this architecture, headings are host markup.\n\nThe two halves of that input are not the same kind of thing. The asset config is\n*descriptive*: `src/pipeline/types.ts` records what the exporter has actually written\nacross four shape changes under one unchanged `schema_version`, and the adapter absorbs\nall of it. The family manifest is a *contract*, schema 4.0, authored and validated in\n`../fabric-creation/src/manifest.py`. It is the published contract for a material family\nrather than for fabric: cloth, wood and metal all publish this one shape — three shared maps\nand a list of tints — which is why `PipelineFabricManifest`, its `Textures` and `Factors`,\nand `fabricManifestToLibrary` shed the word and are now `PipelineFamily*` and\n`familyManifestToLibrary`. `readFabricRef` and the asset-config types keep theirs, because\n`fabric` is the key the exporter actually writes on a surface and a reader named after what\nit wishes it read would send someone looking for a field that is not there.\n\n```jsonc\n{\n  \"schema_version\": \"4.0\",\n  \"family\": \"Haurdic_Powdercoat\", \"version\": \"1.0.0\",\n  \"tile_size_m\": { \"u\": 0.06, \"v\": 0.06 },      // always {u,v}, never a scalar\n  \"textures\": { \"base_color\": \"…\", \"normal\": \"…\", \"orm\": \"…\" },   // one set per family\n  \"factors\": { \"metallic\": 0.0, \"roughness\": 1.0 },  // + sheen_roughness on a pile family\n  \"colourways\": [\n    { \"id\": \"haurdic_metal_belo\", \"name\": \"Belo\", \"swatch_srgb\": \"#EFF0EB\",\n      \"base_color_factor\": [1.0, 1.0, 1.0, 1.0] },   // the published film's own colour\n    { \"id\": \"haurdic_metal_crno\", \"name\": \"Crno\", \"swatch_srgb\": \"#26262A\",\n      \"base_color_factor\": [0.022532, 0.022333, 0.027959, 1.0] }  // the same film, tinted\n  ]\n}\n```\n\nA colourway may also carry `metallic` and `roughness`, and there they mean *this one differs\nfrom its family*; absent means the family's value. **Nothing published carries either today.**\nThe pair was added for the retired `Haurdic_Metal`, which held powder coat and bare brass over\none texture set and had to say per colourway which was which, and one family per material took\naway the thing a colourway could differ from. The keys stay in the contract because a reader\nhas to handle them either way and `familyManifestToLibrary` emits them on presence alone — so\na family that publishes two genuinely different materials over one texture set costs a line\nrather than a schema.\n\nEvery key is either a glTF 2.0 metallic-roughness property in snake_case or family\nbookkeeping, so `familyManifestToLibrary` is a rename with no branch in it: presence or\nabsence decides whether a key is emitted, and no value is ever read. A colourway is a tint\nand never carries a map of its own; cloth needing its own base-colour image is a different\nmaterial and gets its own family. Sheen is all-or-nothing across a family — the lobe width on\nthe family, the bloom's hue per colourway — and `retired` appears only when true.\n\nEight families are published, fifty-eight colourways between them. `Mercis_Amsterdam` is\nforty-nine colourways of plush, authored as a Material Maker graph and the only one with a\nsupplier's photographs behind it. Six of the other seven are transcoded from CC0 scans by\n`../fabric-creation/`: `Haurdic_Bukva` and `Haurdic_Hrast` from Poly Haven veneers by\n`make_veneers.py`, and `Haurdic_Orah`, `Haurdic_Powdercoat`, `Haurdic_Brass` and\n`Haurdic_Steel` from ambientCG by `make_finishes.py`. `base_color_factor` is `[1,1,1,1]` on\nevery one of their colourways but one, because the photograph *is* the colour; the exception is\n`haurdic_metal_crno`, which is the white powder coat's own film tinted down, one paint film in\ntwo pigments being exactly what a colourway is. `tile_size_m` is the scanned surface's\npublished real-world size where the library gave one — 0.40 m on walnut, 1.00 m on beech,\n1.83 m on oak, so the grain renders at the size the tree grew it — and a judgement on the three\nmetals, for which ambientCG publishes no dimension at all.\n\n`Haurdic_Lakirano` (crno and belo lakirano) is the seventh, and the one family still authored\nprocedurally in numpy and Pillow, by `make_lacquer.py`. Material Maker is an external GUI step\ndriven by hand against an installed copy of the tool, so a family whose only source is a graph\nnobody here can render is not rebuildable from this repo; and the plush graph would buy nothing\nif it ran, being pile, weave anisotropy and fibre-tip scatter down to an inlined `weave` node,\nnone of which survives contact with a sprayed film. It used to have company: `make_wood.py` and\n`make_metal.py` published the wood and the metal out of arithmetic, and both were rejected on\nhow they looked. Nothing but the plush publishes sheen — there is no pile anywhere else, and\nglTF's default sheen colour is black, which is off.\n\nEight families where a reader expects three is the decision rather than the accident, and it\nwas taken twice. Until 2026-09-01 there was a single `Haurdic_Wood`, five colourways over one\nprocedural grain; until 2026-09-02 a single `Haurdic_Metal`, four finishes over one procedural\nsatin field. Both were rejected, and for one reason: glTF renders base colour as texture ×\nfactor and a family gets one texture, so a shared map under a per-material tint can only ever\nbe one timber in three paints or one metal in four. A timber's identity is its figure and a\nmetal's is its finish, and neither is a colour. `../fabric-creation/README.md` carries both\nretirements and the contract rule behind them. What matters on this side is that the splits\ncost the panel nothing, which is what `type` is for — see below.\n\nThe metals split one further than the woods did, and the ORM blue channel is why. glTF\nmultiplies that channel by `metallicFactor`, which can only ever scale it down: a family\npublishing blue at 0 can never be made metal again by any factor, and one publishing 1 can\nnever be made dielectric by the map alone. `Haurdic_Powdercoat` publishes 0 because a sprayed\ncoat is a dielectric film over the steel; `Haurdic_Brass` and `Haurdic_Steel` publish 1 because\nbare metal is not. Crno and Belo stay together inside the powder coat because they are one film\nin two pigments; Zlatno and Sivo cannot join them, because brass's warm reflectance and steel's\nneutral one are base-colour images and a family gets one.\n\nThe retired `Haurdic_Metal` is why 4.0 exists. It published blue at 1.0 for all four finishes\nand overrode `metallic` to 0 on the two powder coats, which worked, and which is the shape the\nMAJOR bump paid for. It is not the shape on disk any more: there is no shared texture set left\nto override over.\n\nSchema 2.x and 3.x manifests do not parse, for different reasons. 2.x could not be expressed\nhere at all — both families that published it, `Mercis_Africa_Fish` and\n`Mercis_Amsterdam_Lemon`, carried a base-colour image per colourway, and both were retired on\n2026-08-22. 3.x is the narrow case, and it is why the two finish keys cost a MAJOR bump rather\nthan a minor one. The shapes overlap, since a 3.x colourway is a 4.0 colourway that overrides\nnothing, but the traffic does not run both ways: `validate()` rejects an unknown colourway key\noutright, so a 3.0 reader refuses a 4.0 colourway with \"unknown; a colourway is a tint, not a\nmaterial\". The version string is what a reader pins, and the two shapes cannot share one.\n\n## What the demo actually serves\n\nThe demo stopped using AIAssetPipeline model exports on 2026-08-22. `demo/public/models/`\nholds twenty-four bare, self-contained GLBs — geometry Draco-compressed, textures embedded\nwhere there are any — and no subdirectories: there is nothing to fetch beside a model, so a\nmodel needs no folder and no config file of its own. The family textures under\n`demo/public/materials/` are fetched once for the shop, not once per model, which is the\nwhole point of hoisting them to the family.\n\nA model is not servable the moment it is exported, and the way it fails is silent. Meshy\nemits three shapes: a textured export, which carries the unwrap a fabric needs; a *parts*\nexport, which carries `POSITION` and `COLOR_0` alone and states where the surfaces are\nwithout being able to render one; and geometry only, which states neither. The last two\nhave no `TEXCOORD_0`, so there is nothing to map a weave onto — drop one in as it stands\nand the model renders in its own baked material with no swatch lit and no error anywhere.\n`../surface-separator/` is where a model is made servable: it generates the missing unwrap,\nmerges the parts into the surfaces a person named, and writes the figures the catalogue\nneeds. Its README has the decision table.\n\nWhat a bare GLB cannot state about itself lives in `demo/public/catalogue.json`, which is\nhand-authored:\n\n```jsonc\n{\n  \"families\": [                              // what the shop offers, not what a model is\n    { \"id\": \"Mercis_Amsterdam\",   \"type\": \"fabric\",\n      \"manifest\": \"/materials/Mercis_Amsterdam/manifest.json\" },\n    { \"id\": \"Haurdic_Orah\",       \"type\": \"wood\",   // four families share the type \"wood\"\n      \"manifest\": \"/materials/Haurdic_Orah/manifest.json\" },\n    { \"id\": \"Haurdic_Hrast\",      \"type\": \"wood\",   // and three share \"metal\"; the panel\n      \"manifest\": \"/materials/Haurdic_Hrast/manifest.json\" },\n    // … Haurdic_Bukva and Haurdic_Lakirano, both \"wood\", elided\n    { \"id\": \"Haurdic_Powdercoat\", \"type\": \"metal\",  // unions each set into one heading, so\n      \"manifest\": \"/materials/Haurdic_Powdercoat/manifest.json\" },\n    { \"id\": \"Haurdic_Brass\",      \"type\": \"metal\",  // a family per timber species, or per\n      \"manifest\": \"/materials/Haurdic_Brass/manifest.json\" },\n    { \"id\": \"Haurdic_Steel\",      \"type\": \"metal\",  // finish technology, costs no heading\n      \"manifest\": \"/materials/Haurdic_Steel/manifest.json\" }\n  ],\n  \"assets\": [{\n    \"schemaVersion\": 1,\n    \"id\": \"stolica_gaso\",\n    \"name\": \"Stolica Gaso\",                            // no product name in a GLB\n    \"model\": {\n      \"src\": \"/models/stolica_gaso.glb\",\n      \"alt\": \"Stolica Gaso\",\n      \"bounds\": { \"x\": 0.442, \"y\": 0.75, \"z\": 0.461 }  // metres, for the dimensions overlay\n    },\n    \"surfaces\": [\n      { \"target\": \"surface_1\",\n        \"materialName\": \"surface_1_material\", // which glTF material is the configurable one\n        \"type\": \"fabric\",                     // heading copy, and which families may dress it\n        \"tiling\": 1.488383,                   // metres of surface one UV unit covers, measured\n        \"default\": \"mercis_amsterdam_164\" },\n      { \"target\": \"surface_2\", \"materialName\": \"surface_2_material\", \"type\": \"metal\",\n        \"tiling\": 1.491094, \"default\": \"haurdic_metal_crno\" }\n    ]\n  }]\n}\n```\n\nAn entry is a `ShowroomConfig` verbatim, so it is `name` and a `model` object — not `label`\nand a bare URL. `demo/index.html` installs it without translating, and a renamed key is not\na validation error: the surface simply never resolves.\n\n`surfaces` entries are `Surface` objects verbatim, so `demo/index.html` lifts them\nstraight into a `ShowroomConfig`; adding a model means adding an entry, not learning a\nsecond format. `fromAssetPipeline` is not on that path any more — routing a hand-authored\ncatalogue through an adapter for a format the demo no longer serves would be indirection\nfor its own sake. The family manifests still go through it, because that is a published\nformat with a contract behind it.\n\n`families` is the other half of the file, and each entry now carries a `type` beside its\nmanifest URL. The demo fetches all eight manifests once, then offers a surface only the\nfamilies of its own type: a chair frame gets the metal finishes, a chair's woodwork gets the\nwood, and neither is asked to wear plush. A type with no published family gets an empty list\nrather than an error — leather is that case today, and a leather surface saying there is no\npalette yet is the honest answer rather than a defect.\n\n**Several families may share a type, and `optionsFor` unions them into one group.** It\nfilters `families` on the type and flat-maps every match, so the four wood families render as a\nsingle DRVO heading of five swatches and the three metal families as a single METAL heading of\nfour, both in catalogue order — the same two panels that one five-colourway family and one\nfour-colourway family produced. That union is what makes a family per timber species and a\nfamily per finish technology free here: each costs a catalogue entry and one fetch on first\nuse, not a heading of its own, and neither `demo/index.html` nor the library changed when wood\nwent from one family to four or metal from one to three. Ordering is the catalogue's, since the\nunion preserves the order the families are listed in.\n\n`type` sits on the catalogue entry and not in the manifest because it is merchandising, not a\nglTF property. Every key in a manifest is something a renderer reads or family bookkeeping,\nand which surfaces a family may dress is neither: it is a decision this shop makes, the next\nshop may make differently, and it moves the day a family is published rather than the day one\nis authored. In the manifest it would also make changing one's mind a MAJOR bump.\n\nThe library itself still never branches on `Surface.type`. It stays an open string the\nelements carry and never read, and the gate is one filter over `families` in\n`demo/index.html`'s `optionsFor`. A library that switched on the string would need a case for\nevery material class anyone ever ships, and publishing a leather family would become a\nlibrary release instead of a catalogue edit.\n\n**`tiling` is the one number that has to be measured.** Every model here is a single-atlas\nunwrap normalised to 0..1, so one UV unit covers metres of surface, not centimetres — 1.31 m\non Tabure Duo, 3.42 m on Fotelja Bundeva, 5.95 m on Krevet Slavonika's bedding. Against\nAmsterdam's 25.6 mm weave those are UV repeats of 51x, 134x and 232x, which is what makes\nthe cloth render at life size. Omit it and `uvScaleFor` returns 1 — one 25.6 mm tile\nstretched across a whole chair.\n\nMeasure it as an **area ratio**, `sqrt(3D area / UV area)`, summed over the surface and\ndivided once. The two per-triangle estimators — 3D-to-UV edge lengths, and the Jacobian of\nthe UV-to-model map — are dominated by triangles that are near-degenerate in UV after 12-bit\nquantisation, and read 2.46 and 40.2 against a true 2.2494.\n\n**Measure it per surface, not per model.** An unwrap is rarely uniform: Balvana's backrest\nreads 3.84 against its seat's 3.60. One shared figure has to be wrong on one of them, and\nthe weave then renders at the wrong scale there with nothing on screen to say why. A figure\nis also only valid for the unwrap it was taken from — re-unwrap a model and every tiling in\nits entry is stale, which is what `../surface-separator/check-catalogue.mjs` exists to catch.\n\nIt may be a `{u, v}` pair. Nothing in the catalogue needs one today — the generated unwraps\ncome out near-isotropic, Tabure Duo's two surfaces reading 1.306 and 1.311 — but an atlas\nunwrapped wider than it is tall would stretch the weave along one axis, and stating both\naxes is how that is expressed.\n\n**An AIAssetPipeline export must have its `KHR_texture_transform` dropped before it ships.**\nThose exports normalise UV0 to 0..1 and declare the tiling as a texture transform baked for\nwhatever fabric they shipped wearing. `applyVariant` *multiplies* that transform by\n`tiling / tileSize` rather than replacing it, deliberately — and those exports carry no\nmetallic-roughness texture, so base colour and normal would inherit the baked baseline while\nthe ORM map started from 1. The weave would render at true scale with its roughness several\ntimes too coarse, and nothing on screen would name the cause. Removing the extension puts\nevery slot on the same baseline of 1, and the scale it carried becomes the catalogue's\n`tiling`: Tabure Duo's old export carried 8.114317 against a 0.16 m tile, which is the\n1.298291 m per UV unit that mesh measured. Nothing in `demo/public/models/` carries the\nextension now, and the guard belongs to whoever promotes the next one.\n\n`scripts/copy-demo-assets.mjs` merges into this file rather than regenerating it, and will\nnot mirror upstream model exports unless passed `--models`. Both guards exist because the\nmaterial name and the tiling are unrecoverable once overwritten.\n\nTwo checks stand behind the catalogue, and they answer different questions. Run both after\nadding or re-exporting a model:\n\n```bash\nnode ../surface-separator/check-catalogue.mjs   # does each entry match the GLB on disk?\nnpm run shoot:catalogue                         # does each one render wearing a material?\n```\n\nThe first is static — material names, measured tiling, bounds, and whether every `default`\nnames a colourway published by one of the families. The second loads all twenty-four in\nheadless Chromium and saves a screenshot of each, because \"renders untextured\" produces no\nerror anywhere and is only visible by looking.\n\n## Install and build\n\n```bash\nnpm install\nnpm run demo:assets     # copy the demo GLB + textures out of AIAssetPipeline (optional)\nnpm run dev             # http://localhost:5273/demo/\nnpm test                # vitest\nnpm run spike           # material-swap assertions in headless Chromium\nnpm run build\n```\n\nOutputs:\n\n| File | Format | model-viewer |\n|---|---|---|\n| `model-showroom.js` | ESM | inlined |\n| `model-showroom.iife.js` | IIFE, for a plain `<script src>` | inlined |\n| `model-showroom.pipeline.js` | ESM | absent |\n\nmodel-viewer is loaded through a dynamic import guarded by\n`customElements.get('model-viewer')`, so a host page that already provides it keeps its\nown copy and does not get a second three.js. Use `defineModelShowroom('acme')` to rename\nthe elements if `model-showroom` collides with something you already own.\n\n## Deploying the microsite\n\n`npm run build` produces the *library*. The demo — the microsite itself — is a separate\nstatic build, because `demo/index.html` imports `/src/*.ts` directly and needs bundling\nbefore a browser can be handed it:\n\n```bash\nnpm run build:site      # -> dist-site/, ~104 MB: 71 MB of models, 11 MB of textures, and\n                        # 22 MB of demo/public/media/. That last one is gitignored and\n                        # dockerignored but NOT excluded from vite's publicDir copy, so a\n                        # local build carries it and the Docker image (~75 MB) does not.\nnpm run preview:site    # http://localhost:4173/demo/\n```\n\n`vite.site.config.ts` keeps `root` at the project root so those absolute `/src/…`\nspecifiers still resolve, which puts the entry at `dist-site/demo/index.html`; `nginx.conf`\nserves it at `/`. `demo/public/` becomes the site root, so `/catalogue.json`,\n`/models/…` and `/materials/…` are the same URLs in dev and in production — the deployed\nsite runs the same integration path the dev server does, rather than a pre-baked copy.\n\n`Dockerfile` builds that in `node:22-alpine` and copies the result into `nginx:1.27-alpine`;\nnothing Node-shaped survives into the runtime image.\n\n```bash\ndocker compose up --build     # then http://localhost:8080/ if you add a ports mapping\n```\n\n### Coolify\n\nNew Resource → **Docker Compose**, base directory `/`. Nothing else to configure:\n\n- `SERVICE_FQDN_MICROSITE_80` in `docker-compose.yml` makes Coolify generate a domain,\n  point its proxy at port 80 and terminate TLS. Set a real domain under the service's\n  **Domains** field before the first deploy — **AR needs HTTPS on a proper hostname**, so\n  the generated `sslip.io` domain will not do for the Quick Look / WebXR path.\n- There is deliberately no `ports:` mapping. Coolify's proxy already owns 80 and 443 on\n  the host; the container is reached over the internal network via `expose`. Add\n  `ports: [\"8080:80\"]` locally if you want to hit it without the proxy.\n- The healthcheck is what Coolify polls to decide a deploy succeeded.\n\n### When a re-exported model does not show up\n\nModels are baked into the image, and their URLs are stable — `catalogue.json` carries the\nversion, the filename never changes. Two things follow, and they are the whole list:\n\n- **Rebuild, don't restart.** `docker compose up` reuses an existing image and never\n  re-reads the checkout; it has to be `up --build`, or Redeploy in Coolify. A push on its\n  own only rebuilds where auto-deploy is wired to the webhook.\n- **Nothing in the path busts a cache**, so `nginx.conf` serves `.glb`/`.json` as\n  `public, no-cache` — stored, but revalidated, and answered with a 304 while unchanged. A\n  browser that predates that rule may still be sitting on an hour of `expires 1h`; one\n  hard reload clears it. If a hard reload changes the model, it was the cache; if it does\n  not, the image was never rebuilt.\n\n## Notes for integrators\n\n- **AR** ships as `webxr quick-look` only. Scene Viewer is excluded on purpose: it\n  launches from the original GLB URL, so an Android user would configure a chair and then\n  see the *unconfigured* chair in their room.\n- **Textures** are cached per `(url, usage)` and never evicted. model-viewer offers no way\n  to dispose a texture it created, so reuse is the only way to bound GPU memory.\n- **Baked occlusion is never touched.** It lives on `TEXCOORD_1` with a per-surface map,\n  while base colour and normal are on `TEXCOORD_0`.\n- **An unresolvable variant is a hard stop**, not a fallback. A retired colourway is gone;\n  showing a neighbouring colour instead would be worse than showing an error.\n\n`docs/specs/` carries the design, including the model-viewer behaviours the implementation\ndepends on and why each one is load-bearing.\n","readmeFilename":"README.md","_rev":"1-57c84c57cf12aeac4069cc0d573fcf27"}