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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
dist/
dist-demo/
*.tsbuildinfo
.DS_Store
4 changes: 4 additions & 0 deletions packages/twin-renderer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
dist-demo
*.tsbuildinfo
6 changes: 6 additions & 0 deletions packages/twin-renderer/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# DESIGN

- **Kinfolk test:** every frame must fit a Kinfolk spread — warm paper tones, restraint, no clutter. A sterile gray-studio render is a regression.
- **Soft natural light:** Poly Haven CC0 HDRI key + warm directional fill, never a hard three-point rig.
- **DOF on hero preset:** the `hero` camera carries shallow depth-of-field + subtle bloom; `pdp`/`lifestyle` stay crisp.
- **No debug overlays in prod:** no axes/grid/stats helpers in the exported library — only the product, its shadow, and the post stack.
125 changes: 125 additions & 0 deletions packages/twin-renderer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# @shoploop/twin-renderer

Browser-side Three.js renderer for `product_twin.usda` files emitted by
[`@shoploop/connector-shopify`](../../connectors/shopify). It is the web-facing
counterpart to the open-imagine GPU render path — **same source-of-truth USD,
two consumers**: an interactive browser preview here, an offline EXR render there.

- Minimal USDA → scene-graph parser (no heavy USD libs, no WASM).
- `react-three-fiber` renderer with an editorial light/post rig (HDRI, DOF, bloom, ground shadow).
- Variant sets from the twin become runtime UI controls.
- External GLB/USDZ mesh refs load in-browser; placeholder twins render a tasteful procedural stand-in.

## Install

```sh
pnpm add @shoploop/twin-renderer three @react-three/fiber @react-three/drei @react-three/postprocessing
```

`react`, `react-dom`, and the Three stack are peer/externalized — the host app owns them.

## Quick start

```tsx
import { ProductTwin, VariantSelector } from "@shoploop/twin-renderer";

export function Preview() {
return (
<div style={{ width: 640, height: 480 }}>
<ProductTwin
productTwinUrl="/twins/varitea_product_twin.usda"
cameraPreset="hero"
onVariantChange={({ selection, commerce }) =>
console.log(selection, commerce["commerce:price"])
}
>
<div style={{ position: "absolute", left: 16, bottom: 16 }}>
<VariantSelector />
</div>
</ProductTwin>
</div>
);
}
```

`<ProductTwin>` fetches and parses the `.usda`, builds the scene, and provides a
context that the built-in `<VariantSelector>` reads. Drop the selector anywhere
inside `<ProductTwin>` as an HTML overlay, or use it standalone with explicit
`stage`/`value`/`onChange` props.

### Props (`ProductTwin`)

| prop | type | notes |
|---|---|---|
| `productTwinUrl` | `string` | URL/path to a `.usda` from `shoploop-shopify ingest` |
| `stage` | `ProductTwinStage?` | pre-parsed stage; skips the fetch |
| `initialVariants` | `Record<string,string>?` | initial selection, e.g. `{ size: "_100g" }` |
| `onVariantChange` | `(c: VariantChange) => void` | fires on every selection change |
| `environment` | `string?` | HDRI URL or a drei preset name (`apartment`, `sunset`, …) |
| `cameraPreset` | `"hero" \| "pdp" \| "lifestyle"` | `hero` adds shallow DOF |
| `placeholder` | `Partial<PlaceholderConfig>?` | `{ kind: "tea-tin" \| "box", color, radius, height }` |

## Pointing at a real twin

```sh
# in connectors/shopify
pnpm shoploop-shopify ingest --product <handle> > varitea_product_twin.usda
```

Serve the `.usda` as a static asset and pass its URL to `productTwinUrl`. When
the connector attaches a Shopify Model3d (`prepend references = @…glb@`), the
renderer fetches and loads it via `GLTFLoader`/`USDZLoader`. When the twin only
carries the placeholder `def Mesh`, a procedural rounded-cylinder tea tin renders
instead — no asset required.

## Embedding

### Next.js (App Router)

The renderer is client-only (WebGL + `fetch`). Wrap it in a client component and
lazy-load to keep it out of the server bundle:

```tsx
"use client";
import dynamic from "next/dynamic";
const ProductTwin = dynamic(
() => import("@shoploop/twin-renderer").then((m) => m.ProductTwin),
{ ssr: false },
);
```

### Remix

Render inside a `<ClientOnly>` boundary (e.g. `remix-utils`) so the canvas only
mounts in the browser; pass the twin URL from your loader data.

There is **no server-side GLB rendering** — all geometry work is browser-side Three.js.

## Performance notes

- Target **60 fps on a 2021 MBP** for the placeholder twin at the `hero` preset.
- Mobile budget: **≥30 fps** mid-tier phones. Bloom + DOF are the heaviest passes —
drop to `pdp`/`lifestyle` (DOF off) or set `bloom={0}` on low-end devices.
- `dpr` is capped at `[1, 2]`; shadow map is 2048² (halve for mobile if needed).
- Geometry is built once per stage; variant switches only restate commerce data,
so the size selector is allocation-free after first load.
- Keep external GLBs **< 2 MB** / **< 50k tris** for a snappy first paint.

## Scripts

```sh
pnpm --filter @shoploop/twin-renderer dev # Vite dev server on :5173 (demo)
pnpm --filter @shoploop/twin-renderer build # typecheck + ESM lib (+ .d.ts) + demo site
pnpm --filter @shoploop/twin-renderer test # vitest (parser + scene builder)
```

## Scope of the USDA parser

Supports exactly the subset the Shopify connector emits: layer metadata +
`customLayerData`, `def`/`over`/`class` prims, `Xform`/`Mesh`/`Material`/`Scope`,
prim metadata (`kind`, `variantSets`, `variants`, `references`, `apiSchemas`),
typed attributes (`string`/`int`/`float`/`bool`/`token`/`asset` + `[]` arrays +
numeric tuples), relationships, `variantSet` blocks, and the `commerce:*` /
`brand:*` namespaces. It is **not** a general OpenUSD implementation.

See [DESIGN.md](./DESIGN.md) for the aesthetic contract.
31 changes: 31 additions & 0 deletions packages/twin-renderer/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Varitea · Product Twin</title>
<style>
:root {
--paper: #f3ede2;
--ink: #2b2b2b;
--accent: #6f7d5c;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
background:
radial-gradient(120% 120% at 50% 0%, #fbf7ef 0%, var(--paper) 60%, #e8e0d2 100%);
color: var(--ink);
min-height: 100%;
display: grid;
place-items: center;
padding: 48px 20px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
75 changes: 75 additions & 0 deletions packages/twin-renderer/demo/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import { ProductTwin, type VariantChange, VariantSelector } from "../src/index.js";

const USDA_URL = "/varitea_product_twin.usda";

function priceLabel(commerce: VariantChange["commerce"]): string {
const price = commerce["commerce:price"];
const value = commerce["commerce:option_value"];
const sku = commerce["commerce:sku"];
const parts = [
value ? String(value) : null,
price ? `$${String(price)}` : null,
sku ? String(sku) : null,
].filter(Boolean);
return parts.join(" · ");
}

function App() {
const [change, setChange] = useState<VariantChange | null>(null);

return (
<div
style={{
width: "min(960px, 92vw)",
background: "#fffaf2",
borderRadius: 24,
boxShadow: "0 30px 80px -40px rgba(60,45,30,0.5), 0 2px 0 rgba(255,255,255,0.6) inset",
overflow: "hidden",
border: "1px solid rgba(120,100,70,0.12)",
}}
>
<div
style={{
padding: "28px 32px 18px",
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<div>
<div style={{ fontSize: 13, letterSpacing: "0.22em", textTransform: "uppercase", color: "#6f7d5c" }}>
Drink Varitea
</div>
<h1 style={{ margin: "6px 0 0", fontSize: 28, fontWeight: 600 }}>Loose-Leaf — Original</h1>
</div>
<div style={{ fontSize: 15, color: "#7a6f60" }}>{change ? priceLabel(change.commerce) : "—"}</div>
</div>

<div style={{ height: 460, background: "linear-gradient(180deg,#fffdf8 0%, #f4ecdd 100%)" }}>
<ProductTwin
productTwinUrl={USDA_URL}
cameraPreset="hero"
onVariantChange={setChange}
>
<div style={{ position: "absolute", left: 24, bottom: 20 }}>
<VariantSelector />
</div>
</ProductTwin>
</div>

<div style={{ padding: "20px 32px 28px", color: "#5f574b", fontSize: 14, lineHeight: 1.5 }}>
Small-batch loose-leaf tea blend. Stone-fruit and chamomile notes, cold-brew friendly.
Steep 3–5 min at 90°C. Geometry is a procedural placeholder until a GLB/USDZ ref lands in the
twin — same source-of-truth USD the open-imagine GPU path consumes.
</div>
</div>
);
}

createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
58 changes: 58 additions & 0 deletions packages/twin-renderer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@shoploop/twin-renderer",
"version": "0.1.0",
"description": "Browser-side Three.js renderer for product_twin.usda emitted by @shoploop/connector-shopify. Web counterpart to the open-imagine GPU render path.",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"sideEffects": false,
"scripts": {
"dev": "vite --mode demo --port 5173",
"build": "tsc -p tsconfig.build.json --noEmit && vite build && vite build --mode demo",
"build:lib": "vite build",
"build:demo": "vite build --mode demo",
"preview": "vite preview --mode demo --port 5173",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10",
"@react-three/postprocessing": "^2.16.3",
"three": "^0.169.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^24.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-plugin-dts": "^4.2.0",
"vitest": "^2.1.0"
},
"engines": {
"node": ">=20"
}
}
82 changes: 82 additions & 0 deletions packages/twin-renderer/public/varitea_product_twin.usda
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#usda 1.0
(
defaultPrim = "Product_varitea_loose_leaf_original"
metersPerUnit = 1
upAxis = "Y"
customLayerData = {
string "commerce:shop" = "drinkvaritea.myshopify.com"
string "commerce:shopify_id" = "gid://shopify/Product/7700112233445"
string "commerce:generator" = "@shoploop/connector-shopify"
string "commerce:generated_at" = "2026-06-20T12:00:00.000Z"
}
)

def Xform "Product_varitea_loose_leaf_original" (
kind = "component"
variantSets = "size"
variants = {
string size = "_50g"
}
)
{
custom string commerce:title = "Varitea Loose-Leaf — Original"
custom string commerce:handle = "varitea-loose-leaf-original"
custom string commerce:vendor = "Drink Varitea"
custom string commerce:product_type = "Tea"
custom string commerce:status = "ACTIVE"
custom string commerce:description = "Small-batch loose-leaf tea blend. Stone-fruit and chamomile notes, cold-brew friendly. Steep 3–5 min at 90°C."
custom string commerce:price = "14.00"
custom string commerce:sku = "VAR-LL-50"
custom int commerce:inventory = 120
custom string[] brand:tags = ["loose-leaf", "tea", "wellness", "cold-brew"]
custom string brand:rules_ref = "/etc/shoploop/brand_rules.json"

def Scope "Looks"
{
def Material "PreviewMat"
{
custom asset commerce:preview_image = @https://cdn.shopify.com/s/files/1/varitea/loose-leaf-original.jpg@
custom string commerce:preview_alt = "Varitea loose-leaf original in a kraft pouch on linen"
token outputs:surface
}
}

def Mesh "Placeholder" (
prepend apiSchemas = ["MaterialBindingAPI"]
)
{
float3[] extent = [(-0.5, -0.5, -0.5), (0.5, 0.5, 0.5)]
int[] faceVertexCounts = [4, 4, 4, 4, 4, 4]
int[] faceVertexIndices = [0, 1, 3, 2, 2, 3, 5, 4, 4, 5, 7, 6, 6, 7, 1, 0, 1, 7, 5, 3, 6, 0, 2, 4]
point3f[] points = [(-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5)]
uniform token subdivisionScheme = "none"
rel material:binding = </Product_varitea_loose_leaf_original/Looks/PreviewMat>
custom bool commerce:placeholder = true
}

variantSet "size" = {
"_50g" {
custom string commerce:option_value = "50g"
custom string commerce:sku = "VAR-LL-50"
custom string commerce:price = "14.00"
custom int commerce:inventory = 120
custom string commerce:variant_id = "gid://shopify/ProductVariant/41001"
}

"_100g" {
custom string commerce:option_value = "100g"
custom string commerce:sku = "VAR-LL-100"
custom string commerce:price = "24.00"
custom int commerce:inventory = 80
custom string commerce:variant_id = "gid://shopify/ProductVariant/41002"
}

"_250g" {
custom string commerce:option_value = "250g"
custom string commerce:sku = "VAR-LL-250"
custom string commerce:price = "52.00"
custom int commerce:inventory = 35
custom string commerce:variant_id = "gid://shopify/ProductVariant/41003"
}
}
}
Loading