Holloway Build notes

Build notes

What is under the Barham page

Seven pieces of work, each with the check that says it is actually doing what it claims. Every number on this page came out of a script in tools/, not off a spec sheet.

01 One file, four upholsteries

The four fabrics are not four models and not four textures swapped by hand. They ride inside a single glTF as KHR_materials_variants, the extension written for exactly this: one mesh, one download, the material changed on the client. Picking a swatch costs no network request and no reload.

Fabric gets a second extension, KHR_materials_sheen, which is the retroreflective lobe you see when light catches the ends of fibres. It is the difference between velvet and painted plastic. The velvet runs a strong, broad lobe; the linen a weak, tight one.

sofa.glb  6405 KB · 9312 tris upholstery, 256 tris legs · 4 variants

validator 2.0.0-dev.3.10 | ERRORS: 0
warnings 0 infos 0 hints 0
extensions used: [ 'KHR_materials_sheen', 'KHR_materials_variants' ]
drawCalls 2 verts 27888 tris 9568
The Khronos glTF validator, clean. Two draw calls: upholstery and legs.

Two warnings had to be fixed to get there. The mesh was shipping without a tangent basis, so every renderer would have invented its own and the normal maps would have looked different on iOS than on Chrome; that is now MikkTSpace tangents baked at build time. And the legs carried a UV set no material read, which is dead weight in the download.

02 Augmented reality, both platforms

Android reads the glTF directly through Scene Viewer, variants and all. iOS does not: AR Quick Look wants USDZ, and while USD has variant sets, Quick Look gives the viewer no way to switch them. So the build writes one USDZ per fabric and the page repoints ios-src when a swatch is picked.

ok  sofa-ivory-linen.usdz    1808 KB  usd-validate: PASS
ok  sofa-sage-twill.usdz     1756 KB  usd-validate: PASS
ok  sofa-camel-boucle.usdz   2045 KB  usd-validate: PASS
ok  sofa-ink-velvet.usdz     1856 KB  usd-validate: PASS

package members:
     845 KB  sofa_ivory-linen.usdc
     590 KB  0/ivory-linen_normal.png
     186 KB  0/ivory-linen_orm.jpg
     131 KB  0/ivory-linen_basecolor.jpg
up axis: Y | metersPerUnit: 1.0 | unresolved textures: 0
Written with OpenUSD, checked with OpenUSD’s own validators.

Two details that decide whether AR is useful or a toy. The stage is set to metersPerUnit 1.0 and the model is built at true size, so the piece lands in the room at 216 cm and not at whatever looks nice. And ar-scale="fixed" takes away pinch-to-zoom, because the question a shopper is really asking is whether it fits, and a sofa you can shrink cannot answer that.

Not verified: nobody has pointed a real iPhone at these. The packages pass OpenUSD’s validators and resolve all their textures, which is as far as a Windows machine can take it. First run on a real handset is a half hour of somebody’s time and should happen before any of this is shown to a client.

03 Four weaves, built rather than found

The fabrics are generated from a model of the interlacing itself. A cell grid records which yarn floats over which, the yarn’s crimp is read back off that grid along its own length, and the cross-section is a semi-ellipse. Plain weave and 2/2 twill fall out of the same code with a different pattern. Bouclé is not an interlacing at all, so it gets looped slubs over a buried ground weave, and velvet is a cut pile: almost no relief, all of its character in the sheen.

Contact sheet of the four fabrics showing base colour, normal map and packed occlusion-roughness-metalness map
Base colour, normal, and packed ORM for each weave. The plain weave checkerboard, the twill's diagonal wale, and the bouclé loops are structural, not noise.

Maps are packed the way glTF expects: occlusion in red, roughness in green, metalness in blue, one texture instead of three. UVs are box-projected per triangle at a fixed texel density, so the weave is the same physical size on the arm as on the seat rather than stretching across the panels.

04 Colour, measured

A textile client’s first question about a website is whether the colour on screen matches the cutting they post out. The honest answer is that a lit 3D view never matches a flat swatch, because the light is part of the picture. The useful answer is to measure the gap and then decide what to do about it.

This samples the rendered seat, averages the source swatch in linear light, converts both to Lab and reports CIEDE2000. Under about 1 the difference is invisible, 1 to 2 is visible side by side, over 3 is an argument with a client.

CIEDE2000 vs the flat swatch, by exposure (tone mapping: commerce)

fabric              0.55   0.70   0.85   1.00   1.05   1.20
ivory-linen          8.0    2.2    2.2    4.7    5.2    6.1
sage-twill           5.5    1.2    6.1   10.2   11.4   14.7
camel-boucle         8.9    2.7    3.0    7.0    8.2   11.5
ink-velvet          11.5   17.2   22.3   27.1   28.6   32.7
mean                 8.5    5.8    8.4   12.3   13.4   16.3

lowest mean dE at exposure 0.7 (5.85)
Exposure was set from this table, not by eye. Mean error fell from 13.4 to 5.9 and three of the four fabrics landed under dE 3.

The velvet refuses to come along, and that is the finding worth keeping. Dark cloth diverges hardest in a lit view, because any light you add is proportionally enormous against a dark base. Dropping its sheen to zero only recovered about six points of the gap, so the material is not the problem, the lighting is. Which is the argument for the flat memo sample sitting under the viewer: colour is judged there, on a neutral 18% grey ground, and the 3D view is for form and scale.

Two smaller things. The tone mapping labelled commerce and the one labelled neutral are the same mapper in model-viewer 4.3.1; the internal table has no entry for either name, so both fall through to the Khronos PBR Neutral operator. The measurements agree to the digit. And the swatch JPEGs carry no ICC profile, which is what we want here: no profile means browsers read them as sRGB, which is what they are.

05 WordPress

A plugin, running in real WordPress, offering the viewer as a block and as a [holloway_viewer] shortcode. Both go through one render function, so there is a single markup path rather than two that drift apart.

A WordPress post showing the block and the shortcode, each with its own fabric selected
One post, two independent viewers: the block opened on Sage Twill, the shortcode on Ink Velvet, each with its own specification.

The parts a review would look for. Assets enqueue only on pages that actually render a viewer, rather than site-wide. model-viewer is registered with wp_register_script_module, because it ships as an ES module and a classic script tag loads it into silence. Every attribute is escaped on output and sanitised on input. Block metadata lives in block.json with an editor.asset.php manifest beside the editor script, without which the editor script loads with no dependencies and wp.blockEditor is undefined by the time the block tries to register. The fabric range comes from a filterable function, so a real site can point it at a custom post type without touching the plugin.

Configuration rides on a data attribute rather than a global, which is why two viewers on one page keep their own state instead of fighting.

06 Squarespace

Squarespace does not take plugins, so this is code injection: one block of markup pasted into the footer that finds the product gallery, puts the viewer in front of it, and wires the swatches to Squarespace’s own variant select so the cart still takes the right SKU.

A Squarespace-style product page with the 3D viewer in place of the gallery and the variant select still present
The snippet running against a local stand-in for a 7.1 commerce page.

The thing that breaks injected code on Squarespace is that 7.1 navigates over Ajax. Click an internal link and the body is swapped and mercury:load is fired, with no page reload. A script that listens only for DOMContentLoaded works on the first page a visitor lands on and never again, which is why so much injected code "randomly stops working". The snippet listens for both and refuses to mount a second viewer on re-entry.

Also worth knowing before quoting the job: Code Injection needs a Business plan, Squarespace’s file uploader rejects .glb and .usdz so the models have to live on a CDN, and appending ?format=json-pretty to any Squarespace URL dumps the page’s product JSON, which is the fastest way to get the exact variant strings the snippet has to match.

Not verified: this has not run on a live Squarespace account. The harness reproduces the 7.1 product DOM and its Ajax lifecycle, which is where injected code actually fails, but it cannot catch template-specific CSS collisions. Budget an hour on the real site.

07 AI product photography, and its limit

The room is generated locally with SDXL Turbo. The sofa is not, and never should be: ask a diffusion model for a sofa and it invents one, with the wrong arm, the wrong leg, and a fabric the mill does not sell. The room is set dressing. The piece is the product, and it comes off the same glTF the shopper configures.

The sofa composited into a generated room, lit by a neutral studio probe
Studio probe. Fabric colour is right, but the sun rakes in from the right of the room and the piece is lit from the front left.
The same composite with the piece lit by the generated room image
Room fed back in as the environment. The light lands on the correct side and the colour drifts yellow.

That second image is the experiment failing usefully. A photograph is not a light probe: most of its area is dark wall and floor, so total irradiance collapses and the piece renders muddy. An auto-exposure loop matching the plate’s mean luminance to the studio version fixed the brightness in five passes, but nothing fixes the colour, because the room’s warm plaster dominates the integral and tints the cloth.

So the studio plate ships, and the honest conclusion is that a real in-situ photograph is a different job: an HDRI of the actual room, a shadow catcher, and a render, not a JPEG used as a probe. Worth knowing before promising it.

08 What is checked, automatically

Eighteen assertions across the three integrations, driven through a real browser rather than asserted against a DOM snapshot. They test the things that go wrong quietly: whether a swatch really reached the renderer or only restyled itself, whether the URL is shareable, whether two viewers on one page stay independent, whether the Squarespace snippet survives an Ajax swap.

  PASS  storefront: four variants in the glTF  Ivory Linen, Sage Twill, Camel Boucle, Ink Velvet
  PASS  storefront: swatch switches the glTF variant  Ink Velvet
  PASS  storefront: ios-src follows the fabric  sofa-ink-velvet.usdz
  PASS  storefront: specification re-tickets  100% cotton pile
  PASS  storefront: url is shareable  ?f=ink-velvet
  PASS  storefront: arrow keys move through the rail  Ivory Linen
  PASS  storefront: canvas renders something  497 KB data URL
  PASS  squarespace: no viewer on a non-product page
  PASS  squarespace: mounts after an Ajax page swap
  PASS  squarespace: swatch drives the glTF variant  Ink Velvet
  PASS  squarespace: cart SKU stays in step  sku-04
  PASS  squarespace: native gallery hidden only after mount
  PASS  squarespace: exactly one viewer after re-entry  1 stage(s), 2 mount call(s)
  PASS  wordpress: block and shortcode both render
  PASS  wordpress: each instance keeps its own fabric  Sage Twill / Ink Velvet
  PASS  wordpress: spec rows filled from PHP data
  PASS  wordpress: stage holds a 4:3 box  647 x 486
  PASS  wordpress: instances do not share state  Camel Boucle / Ink Velvet

18/18 checks passed

09 What this does not prove