diff --git a/5ee9e6-color-tree/FieldListing/f76bf38f-52e7-424c-b921-7ab6a48e1dbf.json b/5ee9e6-color-tree/FieldListing/f76bf38f-52e7-424c-b921-7ab6a48e1dbf.json new file mode 100644 index 00000000..02f33504 --- /dev/null +++ b/5ee9e6-color-tree/FieldListing/f76bf38f-52e7-424c-b921-7ab6a48e1dbf.json @@ -0,0 +1,168 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Color Tree", + "summary": "Pick colors the way a colorist does — and see why real color refuses to be a sphere. Color Tree is a field after John Maeda's exploratory study that renders the Munsell color system — the century-old standard describing any color by hue, value (lightness), and chroma (intensity), written like 5G 5/8 — as a living 3D solid, and makes that solid your color picker: one `contains(ColorTreeField)` gives any card the studio as its edit format, and the field stores the picked hex with its Munsell notation. Because Albert Munsell measured human perception, every hue reaches its peak intensity at its own lightness, so the solid grows into a lopsided tree; press TO ITTEN to morph it into the perfect sphere Bauhaus color theorist Johannes Itten imagined, and watch exactly where the idealized model bends the truth. Use it to compare two brand accents at equal lightness, find the most saturated teal that still reads on a dark background, or teach why yellow feels lighter than purple.\n\nOpen the demo: the picker sits on the example card as its edit format — drag to tumble the tree, turn on SOUND for the ambient room tone that breathes with your controls, then open the atlas and leaf through the color pages; hold any chip to copy its hex, and the pick lands in the field with its Munsell notation, shown by the compact embedded and atom formats beside it.\n\nFor AI agents and card authors, the implementation is documented per-export in the specs for reuse: the pure Munsell math (`maxChroma`, `chipHex`, `buildArrays`), the `TreeEngine` three.js patterns (shared uniforms, analytic picking without a raycaster), the GLSL morph shaders, and the WebAudio ambient layer. Findable as: color picker, color field, Munsell, Itten, color theory, color wheel, 3D color space, hue value chroma, perceptual color.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "specs.0": { + "links": { + "self": "../Spec/color-tree-field" + } + }, + "specs.1": { + "links": { + "self": "../Spec/color-tree-studio" + } + }, + "specs.2": { + "links": { + "self": "../Spec/color-tree-field-example" + } + }, + "specs.3": { + "links": { + "self": "../Spec/9d5f8ff4-db84-4f9c-a67b-efd0ad20f56a" + } + }, + "specs.4": { + "links": { + "self": "../Spec/919d5f8f-f4db-444f-9c66-7befd0ad20f5" + } + }, + "specs.5": { + "links": { + "self": "../Spec/util-shaders" + } + }, + "specs.6": { + "links": { + "self": "../Spec/db844f9c-667b-4fd0-ad20-f56a876b907a" + } + }, + "specs.7": { + "links": { + "self": "../Spec/efd0ad20-f56a-476b-907a-80841bbc2d5a" + } + }, + "specs.8": { + "links": { + "self": "../Spec/9c667bef-d0ad-40f5-aa87-6b907a80841b" + } + }, + "specs.9": { + "links": { + "self": "../Spec/d0ad20f5-6a87-4b90-ba80-841bbc2d5ac4" + } + }, + "specs.10": { + "links": { + "self": "../Spec/f56a876b-907a-4084-9bbc-2d5ac497cb20" + } + }, + "specs.11": { + "links": { + "self": "../Spec/ad20f56a-876b-407a-8084-1bbc2d5ac497" + } + }, + "specs.12": { + "links": { + "self": "../Spec/6a876b90-7a80-441b-bc2d-5ac497cb204d" + } + }, + "specs.13": { + "links": { + "self": "../Spec/f4db844f-9c66-4bef-90ad-20f56a876b90" + } + }, + "specs.14": { + "links": { + "self": "../Spec/844f9c66-7bef-40ad-a0f5-6a876b907a80" + } + }, + "specs.15": { + "links": { + "self": "../Spec/7befd0ad-20f5-4a87-ab90-7a80841bbc2d" + } + }, + "specs.16": { + "links": { + "self": "../Spec/667befd0-ad20-456a-876b-907a80841bbc" + } + }, + "specs.17": { + "links": { + "self": "../Spec/4f9c667b-efd0-4d20-b56a-876b907a8084" + } + }, + "specs.18": { + "links": { + "self": "../../Spec/code-snippet" + } + }, + "examples.0": { + "links": { + "self": "../example/ColorTreeFieldExample/dc3c493e-5883-4f74-b6e4-63d2b75795c6" + } + }, + "examples.1": { + "links": { + "self": "../example/ColorTreeFieldExample/bc3ae52b-904d-46b5-9913-9f703f7cac04" + } + }, + "tags.0": { + "links": { + "self": "https://app.boxel.ai/catalog/Tag/field" + } + }, + "categories.0": { + "links": { + "self": "https://app.boxel.ai/catalog/Category/design-creative" + } + }, + "license": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": "../ListingThumbnails/color-tree-62294a09.png" + } + }, + "images.0": { + "links": { + "self": "../Screenshots/01-munsell-tree.png" + } + }, + "images.1": { + "links": { + "self": "../Screenshots/02-itten-sphere.png" + } + }, + "images.2": { + "links": { + "self": "../Screenshots/03-studio-controls.png" + } + }, + "images.3": { + "links": { + "self": "../Screenshots/04-atlas-hue-leaf.png" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/catalog/catalog-app/listing/listing", + "name": "FieldListing" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/ListingThumbnails/color-tree-62294a09.png b/5ee9e6-color-tree/ListingThumbnails/color-tree-62294a09.png new file mode 100644 index 00000000..8e5796fa Binary files /dev/null and b/5ee9e6-color-tree/ListingThumbnails/color-tree-62294a09.png differ diff --git a/5ee9e6-color-tree/Screenshots/01-munsell-tree.png b/5ee9e6-color-tree/Screenshots/01-munsell-tree.png new file mode 100644 index 00000000..f1fbba7f Binary files /dev/null and b/5ee9e6-color-tree/Screenshots/01-munsell-tree.png differ diff --git a/5ee9e6-color-tree/Screenshots/02-itten-sphere.png b/5ee9e6-color-tree/Screenshots/02-itten-sphere.png new file mode 100644 index 00000000..3c844bb7 Binary files /dev/null and b/5ee9e6-color-tree/Screenshots/02-itten-sphere.png differ diff --git a/5ee9e6-color-tree/Screenshots/03-studio-controls.png b/5ee9e6-color-tree/Screenshots/03-studio-controls.png new file mode 100644 index 00000000..d79a227a Binary files /dev/null and b/5ee9e6-color-tree/Screenshots/03-studio-controls.png differ diff --git a/5ee9e6-color-tree/Screenshots/04-atlas-hue-leaf.png b/5ee9e6-color-tree/Screenshots/04-atlas-hue-leaf.png new file mode 100644 index 00000000..00b69320 Binary files /dev/null and b/5ee9e6-color-tree/Screenshots/04-atlas-hue-leaf.png differ diff --git a/5ee9e6-color-tree/Spec/4f9c667b-efd0-4d20-b56a-876b907a8084.json b/5ee9e6-color-tree/Spec/4f9c667b-efd0-4d20-b56a-876b907a8084.json new file mode 100644 index 00000000..8c07258a --- /dev/null +++ b/5ee9e6-color-tree/Spec/4f9c667b-efd0-4d20-b56a-876b907a8084.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# swatchStyle\n\n## Summary\n\nBuilds an `htmlSafe` inline `background-color` style for a swatch element from a hex color, falling back to the card's slate brand color when no color has been picked yet. Used by the embedded and fitted formats.\n\n## Import\n\n```javascript\nimport { swatchStyle } from '../utils/munsell';\n```\n\n## Usage\n\n```hbs\n\n```", + "ref": { + "module": "../utils/munsell", + "name": "swatchStyle" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "swatchStyle", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/667befd0-ad20-456a-876b-907a80841bbc.json b/5ee9e6-color-tree/Spec/667befd0-ad20-456a-876b-907a80841bbc.json new file mode 100644 index 00000000..8eb50048 --- /dev/null +++ b/5ee9e6-color-tree/Spec/667befd0-ad20-456a-876b-907a80841bbc.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# toHex2\n\n## Summary\n\nConverts one color channel in `[0, 1]` to a two-digit lowercase hex byte, clamping out-of-range input.\n\n## Import\n\n```javascript\nimport { toHex2 } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\ntoHex2(1); // 'ff'\ntoHex2(0.5); // '80'\n```", + "ref": { + "module": "../utils/munsell", + "name": "toHex2" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "toHex2", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/6a876b90-7a80-441b-bc2d-5ac497cb204d.json b/5ee9e6-color-tree/Spec/6a876b90-7a80-441b-bc2d-5ac497cb204d.json new file mode 100644 index 00000000..ae9a47c0 --- /dev/null +++ b/5ee9e6-color-tree/Spec/6a876b90-7a80-441b-bc2d-5ac497cb204d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# rgbHex\n\n## Summary\n\nFormats an RGB triple (each channel in `[0, 1]`) as a CSS hex string.\n\n## Import\n\n```javascript\nimport { rgbHex } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nrgbHex(1, 0.5, 0); // '#ff8000'\n```", + "ref": { + "module": "../utils/munsell", + "name": "rgbHex" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "rgbHex", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/7befd0ad-20f5-4a87-ab90-7a80841bbc2d.json b/5ee9e6-color-tree/Spec/7befd0ad-20f5-4a87-ab90-7a80841bbc2d.json new file mode 100644 index 00000000..9da934a8 --- /dev/null +++ b/5ee9e6-color-tree/Spec/7befd0ad-20f5-4a87-ab90-7a80841bbc2d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# lerpWrapDeg\n\n## Summary\n\nSmoothstep-interpolates the display hue angle (`HUE_DEG`, in HSL degrees) at a fractional hue position `h` in `[0, 1)`, taking the short way around the 360\u00b0 circle so interpolation never sweeps through the wrong hues. Returns a degree value normalized to `[0, 360)`.\n\n## Import\n\n```javascript\nimport { lerpWrapDeg } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet deg = lerpWrapDeg(0.05); // display angle between 5R and 5YR\n```", + "ref": { + "module": "../utils/munsell", + "name": "lerpWrapDeg" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "lerpWrapDeg", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/844f9c66-7bef-40ad-a0f5-6a876b907a80.json b/5ee9e6-color-tree/Spec/844f9c66-7bef-40ad-a0f5-6a876b907a80.json new file mode 100644 index 00000000..362c73c1 --- /dev/null +++ b/5ee9e6-color-tree/Spec/844f9c66-7bef-40ad-a0f5-6a876b907a80.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# lerpWrap\n\n## Summary\n\nSmoothstep-interpolates a 10-entry per-hue table (like `C_PEAK` or `V_PEAK`) at a fractional hue position `h` in `[0, 1)`, wrapping around the hue circle so `5RP` blends back into `5R`.\n\n## Import\n\n```javascript\nimport { lerpWrap, C_PEAK } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet peakChroma = lerpWrap(C_PEAK, 0.35); // peak chroma between 5GY and 5G\n```", + "ref": { + "module": "../utils/munsell", + "name": "lerpWrap" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "lerpWrap", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/919d5f8f-f4db-444f-9c66-7befd0ad20f5.json b/5ee9e6-color-tree/Spec/919d5f8f-f4db-444f-9c66-7befd0ad20f5.json new file mode 100644 index 00000000..763863c4 --- /dev/null +++ b/5ee9e6-color-tree/Spec/919d5f8f-f4db-444f-9c66-7befd0ad20f5.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# makeUniforms\n\n## Summary\n\nBuilds the shared uniform set used by all four shader passes (voxels, halo, grid, ring): animation time, tree/sphere morph, atlas open/closed, chroma clamp, slice mode and position, lattice density, glow, scale, plus the camera-basis vectors (`uCamR` / `uCamU` / `uCamF`) and pick anchor as `THREE.Vector3` slots. Lives in `tree-engine` because that module is the single owner of the CDN three.js import.\n\n## Import\n\n```javascript\nimport { makeUniforms, type Uniforms } from '../utils/tree-engine';\n```\n\n## Usage\n\n```javascript\nlet uniforms = makeUniforms();\nlet material = new THREE.ShaderMaterial({\n uniforms,\n vertexShader: VOXEL_VSH,\n fragmentShader: VOXEL_FSH,\n});\nuniforms.uMorph.value = 1; // morph the tree into Itten's sphere\n```", + "ref": { + "module": "../utils/tree-engine", + "name": "makeUniforms" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "makeUniforms", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/9c667bef-d0ad-40f5-aa87-6b907a80841b.json b/5ee9e6-color-tree/Spec/9c667bef-d0ad-40f5-aa87-6b907a80841b.json new file mode 100644 index 00000000..c0e3e88d --- /dev/null +++ b/5ee9e6-color-tree/Spec/9c667bef-d0ad-40f5-aa87-6b907a80841b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# chipColor\n\n## Summary\n\nThe display color of one Munsell chip as an `[r, g, b]` triple in `[0, 1]`, from hue `h` (fraction in `[0, 1)`), value `v` (0\u201310), and chroma `c`. Chips with chroma below 0.5 render as the neutral gray trunk (a slightly cool gray whose lightness tracks value); chromatic chips map through `lerpWrapDeg` and `hslToRgb`.\n\n## Import\n\n```javascript\nimport { chipColor } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet rgb = chipColor(0.7, 3, 12); // a deep purple-blue chip\n```", + "ref": { + "module": "../utils/munsell", + "name": "chipColor" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "chipColor", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/9d5f8ff4-db84-4f9c-a67b-efd0ad20f56a.json b/5ee9e6-color-tree/Spec/9d5f8ff4-db84-4f9c-a67b-efd0ad20f56a.json new file mode 100644 index 00000000..525b3646 --- /dev/null +++ b/5ee9e6-color-tree/Spec/9d5f8ff4-db84-4f9c-a67b-efd0ad20f56a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# TreeEngine\n\n## Summary\n\nOwns the WebGL scene behind the isolated format: the renderer, main and scout-miniature cameras, the four shader passes (voxels, halo, grid, ring), camera framing, and analytic color picking. The isolated component owns the tracked UI state and drives the engine through its targets and shared uniforms.\n\nKey API: `constructor(canvas)`, `buildMesh()` / `buildGuides()` to (re)build geometry, `setDensity(idx)` for the three lattice densities, `buildGrid(mode, slicePos01)` for the slice guides, `tumble(dx, dy)` / `zoom(delta)` / `refit()` / `resize()` for camera control, `pickHex(cx, cy)` returning a `PickResult` (`{ hex, notation }`) or `null`, `loop()` as the render loop, and `dispose()` for teardown.\n\n## Import\n\n```javascript\nimport { TreeEngine } from '../utils/tree-engine';\n```\n\n## Usage\n\n```javascript\nlet engine = new TreeEngine(canvasElement);\nengine.buildMesh();\nengine.buildGuides();\nengine.loop();\n\nlet pick = engine.pickHex(pointerX, pointerY);\nif (pick) {\n console.log(pick.hex, pick.notation); // '#b04a3c', '5R 5/10'\n}\n\nengine.dispose();\n```", + "ref": { + "module": "../utils/tree-engine", + "name": "TreeEngine" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "TreeEngine", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/ad20f56a-876b-407a-8084-1bbc2d5ac497.json b/5ee9e6-color-tree/Spec/ad20f56a-876b-407a-8084-1bbc2d5ac497.json new file mode 100644 index 00000000..4b9b060c --- /dev/null +++ b/5ee9e6-color-tree/Spec/ad20f56a-876b-407a-8084-1bbc2d5ac497.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# hueLabel\n\n## Summary\n\nThe Munsell principal-hue name (`5R`, `5YR`, \u2026 `5RP`) nearest to a fractional hue position `h` in `[0, 1)`.\n\n## Import\n\n```javascript\nimport { hueLabel } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nhueLabel(0); // '5R'\nhueLabel(0.5); // '5BG'\n```", + "ref": { + "module": "../utils/munsell", + "name": "hueLabel" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "hueLabel", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/color-tree-field-example.json b/5ee9e6-color-tree/Spec/color-tree-field-example.json new file mode 100644 index 00000000..d8a0921a --- /dev/null +++ b/5ee9e6-color-tree/Spec/color-tree-field-example.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ColorTreeFieldExample\n\n## Summary\n\nThe playground example for `ColorTreeField` — shows the field the way a card author will meet it: the 3D studio as the edit format, the compact embedded/atom read-outs, and the one-line `contains()` needed to adopt it.\n\n## Import\n\n```javascript\nimport { ColorTreeFieldExample } from '../example/color-tree-field-playground-example';\n```", + "ref": { + "module": "../example/color-tree-field-playground-example", + "name": "ColorTreeFieldExample" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "ColorTreeFieldExample", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/color-tree-field.json b/5ee9e6-color-tree/Spec/color-tree-field.json new file mode 100644 index 00000000..6fb2dfaa --- /dev/null +++ b/5ee9e6-color-tree/Spec/color-tree-field.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ColorTreeField\n\n## Summary\n\nA Munsell color pick as a field. Reach for it when a flat color picker is not enough: the **edit format is the full 3D studio** (`ColorTreeStudio`) bounded to the form, so a card author gets perceptual hue/value/chroma picking on any card with one `contains()`. The value is the picked `hex` (a `ColorField`) plus its Munsell `munsell` notation (a `StringField`). The embedded format is a compact swatch + hex + notation strip; the atom format is a tiny swatch + notation.\n\n## Import\n\n```javascript\nimport { ColorTreeField } from '../color-tree-field';\n```\n\n## Usage\n\n```gts\nexport class MyCard extends CardDef {\n @field accentColor = contains(ColorTreeField);\n}\n```\n\nRendering:\n\n```hbs\n<@fields.accentColor @format='edit' /> {{! the 3D studio picker }}\n<@fields.accentColor /> {{! swatch + hex + notation }}\n<@fields.accentColor @format='atom' /> {{! tiny swatch + notation }}\n```\n\nPicking happens in the studio's atlas view: open the atlas, hold a spot on the page, and the field's `hex`/`munsell` are set (and the hex copied to the clipboard).", + "ref": { + "module": "../color-tree-field", + "name": "ColorTreeField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "ColorTreeField", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/color-tree-studio.json b/5ee9e6-color-tree/Spec/color-tree-studio.json new file mode 100644 index 00000000..1e16e446 --- /dev/null +++ b/5ee9e6-color-tree/Spec/color-tree-studio.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ColorTreeStudio\n\n## Summary\n\nThe interactive Munsell studio as a reusable glimmer component — the room the specimen is held in. `ColorTreeField`'s edit format renders this component; the studio itself owns no field state. The current selection flows in through `@color` / `@munsell`, and picks flow out (debounced 300ms) through `@onPick`. `@compact` bounds the room for form use (panel closed by default, reduced masthead); `@hasLinkedTheme` suppresses the built-in night-sky palette when a Theme card is linked.\n\nIt carries the full experience: tumble/slice/atlas interaction, the Munsell↔Itten morph, contextual hints, the control panel, and the ambient WebAudio layer.\n\n## Import\n\n```javascript\nimport { ColorTreeStudio } from '../components/color-tree-studio';\n```\n\n## Usage\n\n```gts\n\n```", + "ref": { + "module": "../components/color-tree-studio", + "name": "ColorTreeStudio" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "ColorTreeStudio", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/d0ad20f5-6a87-4b90-ba80-841bbc2d5ac4.json b/5ee9e6-color-tree/Spec/d0ad20f5-6a87-4b90-ba80-841bbc2d5ac4.json new file mode 100644 index 00000000..43934a5d --- /dev/null +++ b/5ee9e6-color-tree/Spec/d0ad20f5-6a87-4b90-ba80-841bbc2d5ac4.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# munsellNotation\n\n## Summary\n\nFormats a chip's full Munsell notation from hue `h` (fraction in `[0, 1)`), value `v`, and chroma `c` \u2014 `'5PB 3/12'` for chromatic chips, the neutral form `'N 5/'` when chroma is below 0.5. This is the notation saved to the card's `selectedMunsell` field on pick.\n\n## Import\n\n```javascript\nimport { munsellNotation } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nmunsellNotation(0.7, 3, 12); // '5PB 3/12'\nmunsellNotation(0, 5, 0); // 'N 5/'\n```", + "ref": { + "module": "../utils/munsell", + "name": "munsellNotation" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "munsellNotation", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/db844f9c-667b-4fd0-ad20-f56a876b907a.json b/5ee9e6-color-tree/Spec/db844f9c-667b-4fd0-ad20-f56a876b907a.json new file mode 100644 index 00000000..ec60f757 --- /dev/null +++ b/5ee9e6-color-tree/Spec/db844f9c-667b-4fd0-ad20-f56a876b907a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# maxChroma\n\n## Summary\n\nThe reachable chroma ceiling at hue `h` (fraction in `[0, 1)`) and value `v` (0\u201310). Each principal hue peaks at its own chroma (`C_PEAK`) at its own value level (`V_PEAK`) \u2014 yellow crowns near value 8.5, purple-blue near value 3 \u2014 and falls off quadratically away from that peak. This lopsided canopy is what makes the Munsell solid a tree rather than a sphere.\n\n## Import\n\n```javascript\nimport { maxChroma } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet ceiling = maxChroma(0.2, 8); // how chromatic can 5Y get at value 8?\n```", + "ref": { + "module": "../utils/munsell", + "name": "maxChroma" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "maxChroma", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/efd0ad20-f56a-476b-907a-80841bbc2d5a.json b/5ee9e6-color-tree/Spec/efd0ad20-f56a-476b-907a-80841bbc2d5a.json new file mode 100644 index 00000000..69e6e95d --- /dev/null +++ b/5ee9e6-color-tree/Spec/efd0ad20-f56a-476b-907a-80841bbc2d5a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# chipHex\n\n## Summary\n\nThe display color of one Munsell chip as a CSS hex string \u2014 `chipColor` composed with `toHex2`. Takes hue `h` (fraction in `[0, 1)`), value `v` (0\u201310), and chroma `c`.\n\n## Import\n\n```javascript\nimport { chipHex } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet hex = chipHex(0, 5, 14); // vivid 5R at value 5, e.g. '#c93a52'\n```", + "ref": { + "module": "../utils/munsell", + "name": "chipHex" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "chipHex", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/f4db844f-9c66-4bef-90ad-20f56a876b90.json b/5ee9e6-color-tree/Spec/f4db844f-9c66-4bef-90ad-20f56a876b90.json new file mode 100644 index 00000000..f69c9a61 --- /dev/null +++ b/5ee9e6-color-tree/Spec/f4db844f-9c66-4bef-90ad-20f56a876b90.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# hslToRgb\n\n## Summary\n\nConverts HSL (hue in degrees \u2014 any value, normalized internally; saturation and lightness in `[0, 1]`) to an `[r, g, b]` triple with each channel in `[0, 1]`.\n\n## Import\n\n```javascript\nimport { hslToRgb } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet [r, g, b] = hslToRgb(212, 0.8, 0.5);\n```", + "ref": { + "module": "../utils/munsell", + "name": "hslToRgb" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "hslToRgb", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/f56a876b-907a-4084-9bbc-2d5ac497cb20.json b/5ee9e6-color-tree/Spec/f56a876b-907a-4084-9bbc-2d5ac497cb20.json new file mode 100644 index 00000000..356fc19d --- /dev/null +++ b/5ee9e6-color-tree/Spec/f56a876b-907a-4084-9bbc-2d5ac497cb20.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# buildArrays\n\n## Summary\n\nBuilds the instanced-attribute arrays for the whole voxel lattice at a given hue count and density tier \u2014 every chip the solid can show, plus the gray trunk. Each voxel gets two baked positions (its Munsell-tree address and its Itten-sphere address, which the shaders morph between), its display color, chroma fraction, hue angle, per-chip random seed, value, chroma, and a solved edge size that keeps neighboring cubes from touching. Returns a `ChipArrays` record of plain number arrays ready to upload as buffer attributes.\n\n## Import\n\n```javascript\nimport { buildArrays, type ChipArrays } from '../utils/munsell';\n```\n\n## Usage\n\n```javascript\nlet arrays = buildArrays(20, 1); // 20 hue leaves at medium density\ngeometry.setAttribute(\n 'aTree',\n new THREE.InstancedBufferAttribute(new Float32Array(arrays.tree), 3),\n);\n```", + "ref": { + "module": "../utils/munsell", + "name": "buildArrays" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "buildArrays", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/Spec/util-shaders.json b/5ee9e6-color-tree/Spec/util-shaders.json new file mode 100644 index 00000000..e09f379a --- /dev/null +++ b/5ee9e6-color-tree/Spec/util-shaders.json @@ -0,0 +1,45 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# shaders (utils)\n\n## Summary\nPure GLSL source strings for the four render passes — no imports, no runtime dependencies. `VOXEL_VSH`/`VOXEL_FSH` morph every instanced cube between its three addresses (measured tree, Itten sphere, flattened atlas page) and light it; `HALO_VSH`/`HALO_FSH` add a soft additive glow behind each voxel in 3D; `GRID_VSH`/`GRID_FSH` draw the LINEAR-scale page's complete synthetic grid; `RING_VSH`/`RING_FSH` mark where the measured chips actually sit inside that ideal field.\n\n## Import\n```js\nimport { VOXEL_VSH, VOXEL_FSH } from '../utils/shaders';\n```", + "ref": { + "module": "../utils/shaders", + "name": "VOXEL_VSH" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "shaders utils", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/color-tree-field.gts b/5ee9e6-color-tree/color-tree-field.gts new file mode 100644 index 00000000..95fa7a24 --- /dev/null +++ b/5ee9e6-color-tree/color-tree-field.gts @@ -0,0 +1,125 @@ +// ============================================================================= +// ColorTreeField — a Munsell color pick as a field. Reach for it when a flat +// color picker is not enough: its edit format is the full 3D studio +// (ColorTreeStudio) bounded to the form, so a card author gets perceptual +// hue/value/chroma picking on any card with one `contains()`. The value is +// the picked hex plus its Munsell notation. +// ============================================================================= +import { + Component, + FieldDef, + contains, + field, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import ColorField from 'https://cardstack.com/base/color'; +import PaletteIcon from '@cardstack/boxel-icons/palette'; +import { swatchStyle } from './utils/munsell'; +import { ColorTreeStudio } from './components/color-tree-studio'; + +export class ColorTreeField extends FieldDef { + static displayName = 'Color Tree Pick'; + static icon = PaletteIcon; + + @field hex = contains(ColorField); + @field munsell = contains(StringField); + + static edit = class Edit extends Component { + onPick = (hex: string, notation: string) => { + this.args.model.hex = hex; + this.args.model.munsell = notation; + }; + + + }; + + static embedded = class Embedded extends Component { + + }; + + static atom = class Atom extends Component { + + }; +} diff --git a/5ee9e6-color-tree/color-tree.test.gts b/5ee9e6-color-tree/color-tree.test.gts new file mode 100644 index 00000000..2db8fc46 --- /dev/null +++ b/5ee9e6-color-tree/color-tree.test.gts @@ -0,0 +1,199 @@ +import { click } from '@ember/test-helpers'; +import { module, test } from 'qunit'; + +import { setupBaseRealm } from '@cardstack/host/tests/helpers/base-realm'; +import { renderCard } from '@cardstack/host/tests/helpers/render-component'; +import { setupRenderingTest } from '@cardstack/host/tests/helpers/setup'; + +import { ColorTreeField } from './color-tree-field'; +import { ColorTreeFieldExample } from './example/color-tree-field-playground-example'; +import { + buildArrays, + chipHex, + hueLabel, + lerpWrapDeg, + maxChroma, + munsellNotation, +} from './utils/munsell'; + +import { getLoader } from '../tests/helpers/field-test-helpers'; + +export function runTests() { + module('Rendering | color-tree field', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + + test('color-tree field example renders the studio as the edit format', async function (assert) { + let card = new ColorTreeFieldExample({ + pick: new ColorTreeField({ hex: '#2e8b6a', munsell: '5G 5/8' }), + title: 'Color Tree Field', + }); + await renderCard(getLoader(), card, 'isolated'); + + assert + .dom('[data-test-color-tree-field-edit] canvas.stage') + .exists('the edit format hosts the 3D studio'); + assert + .dom('[data-test-color-tree-field-edit] .room') + .hasClass('compact', 'the studio runs bounded in a form'); + assert + .dom('[data-test-color-tree-field-embedded]') + .containsText('#2e8b6a'); + assert.dom('[data-test-color-tree-field-atom]').containsText('5G 5/8'); + }); + + test('color-tree field renders the designed empty state', async function (assert) { + await renderCard(getLoader(), new ColorTreeFieldExample({}), 'isolated'); + + assert + .dom('[data-test-color-tree-field-edit] canvas.stage') + .exists('the studio renders with no pick saved'); + assert + .dom('[data-test-color-tree-field-embedded]') + .containsText('no chip picked yet'); + }); + + test('color-tree field hint speaks to the view on stage', async function (assert) { + await renderCard(getLoader(), new ColorTreeFieldExample({}), 'isolated'); + + assert.dom('.hint').containsText('drag to tumble'); + assert + .dom('.hint') + .doesNotContainText( + 'copy its hex', + 'no pick hint while the atlas is closed', + ); + + let [, scanButton] = [ + ...document.querySelectorAll('.stage-actions button'), + ]; + await click(scanButton as Element); + + assert.dom('.hint').containsText('copy its hex'); + assert + .dom('.hint') + .doesNotContainText( + 'drag to tumble', + 'no tumble hint on the open atlas', + ); + }); + + test('color-tree field hamburger docks at the panel edge while it is open', async function (assert) { + await renderCard(getLoader(), new ColorTreeFieldExample({}), 'isolated'); + + assert + .dom('.panel') + .doesNotExist('the compact studio starts with the panel closed'); + assert.dom('.hamburger').hasText('☰'); + + await click('.hamburger'); + + assert.dom('.panel').exists('the panel opens'); + assert.dom('.hamburger').hasClass('panel-open'); + assert.dom('.hamburger').hasText('✕'); + }); + + test('color-tree field scan dial only engages while a cut is open', async function (assert) { + await renderCard(getLoader(), new ColorTreeFieldExample({}), 'isolated'); + await click('.hamburger'); + + assert + .dom('input[aria-label="scan"]') + .isDisabled('the scan dial is greyed out on the closed solid'); + + let [, scanButton] = [ + ...document.querySelectorAll('.stage-actions button'), + ]; + await click(scanButton as Element); + + assert + .dom('input[aria-label="scan"]') + .isEnabled('opening the atlas engages the scan dial'); + }); + + test('color-tree field scout miniature toggles away over the open atlas', async function (assert) { + await renderCard(getLoader(), new ColorTreeFieldExample({}), 'isolated'); + + assert + .dom('[data-test-toggle-mini]') + .doesNotExist('no scout toggle while the atlas is closed'); + + let [, scanButton] = [ + ...document.querySelectorAll('.stage-actions button'), + ]; + await click(scanButton as Element); + + assert.dom('.mini-frame').exists('the scout shows on the open atlas'); + assert + .dom('[data-test-toggle-mini]') + .hasAttribute('aria-pressed', 'true'); + + await click('[data-test-toggle-mini]'); + + assert.dom('.mini-frame').doesNotExist('the toggle waves the scout away'); + assert + .dom('[data-test-toggle-mini]') + .hasAttribute('aria-pressed', 'false'); + + await click('[data-test-toggle-mini]'); + + assert.dom('.mini-frame').exists('the toggle brings the scout back'); + }); + + test('color-tree field munsellNotation formats chromatic and neutral chips', function (assert) { + assert.strictEqual(munsellNotation(0, 5, 12), '5R 5/12'); + assert.strictEqual(munsellNotation(0.5, 4, 8), '5BG 4/8'); + assert.strictEqual(munsellNotation(0.3, 4, 0), 'N 4/'); + }); + + test('color-tree field maxChroma peaks at each hue peak and never goes negative', function (assert) { + assert.strictEqual(maxChroma(0, 5), 14, '5R peaks at chroma 14, value 5'); + assert.true( + maxChroma(0, 9) < maxChroma(0, 5), + 'chroma falls off away from the peak', + ); + for (let v = 0; v <= 10; v++) { + assert.true(maxChroma(0.7, v) >= 0, `chroma at value ${v} is >= 0`); + } + }); + + test('color-tree field chipHex returns a css hex color', function (assert) { + assert.true(/^#[0-9a-f]{6}$/.test(chipHex(0, 5, 12))); + assert.true( + /^#[0-9a-f]{6}$/.test(chipHex(0, 5, 0)), + 'neutral chips also format as hex', + ); + }); + + test('color-tree field hue helpers wrap around the hue circle', function (assert) { + assert.strictEqual(hueLabel(0), '5R'); + assert.strictEqual(hueLabel(0.5), '5BG'); + // between 5R (357°) and 5YR (32°) the blend crosses 0°, not 180° + let deg = lerpWrapDeg(0.05); + assert.true(deg >= 0 && deg < 32, `deg ${deg} takes the short way`); + }); + + test('color-tree field buildArrays produces a consistent lattice', function (assert) { + let arrays = buildArrays(10, 0); + let n = arrays.val.length; + assert.true(n > 0, 'the lattice has chips'); + assert.strictEqual(arrays.tree.length, n * 3, 'a tree address per chip'); + assert.strictEqual( + arrays.sphere.length, + n * 3, + 'a sphere address per chip', + ); + assert.strictEqual(arrays.cols.length, n * 3, 'a color per chip'); + assert.strictEqual(arrays.edg.length, n, 'an edge size per chip'); + assert.true( + arrays.edg.every((e) => e > 0), + 'every cube has positive size', + ); + assert.strictEqual( + arrays.chr.filter((c) => c === 0).length, + 11, + 'the gray trunk runs value 0 through 10', + ); + }); + }); +} diff --git a/5ee9e6-color-tree/components/color-tree-studio.gts b/5ee9e6-color-tree/components/color-tree-studio.gts new file mode 100644 index 00000000..fac680f7 --- /dev/null +++ b/5ee9e6-color-tree/components/color-tree-studio.gts @@ -0,0 +1,1392 @@ +// ============================================================================= +// ColorTreeStudio — the room the specimen is held in, as a reusable glimmer +// component: tumble the solid, slice it open (HUE leaf / VALUE plane), open +// the atlas (the same voxels laid flat as a page), morph between Munsell's +// measured tree and Itten's idealized sphere, and pick colors straight off +// the canvas. ColorTreeField's edit format renders this component — the +// studio itself owns no field state; picks flow out through @onPick and the +// current selection flows in through @color / @munsell. All WebGL work +// lives in utils/tree-engine. +// ============================================================================= +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { modifier } from 'ember-modifier'; +import { htmlSafe } from '@ember/template'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { Button } from '@cardstack/boxel-ui/components'; +import EyeIcon from '@cardstack/boxel-icons/eye'; +import EyeOffIcon from '@cardstack/boxel-icons/eye-off'; +import { debounce } from 'lodash'; +import { HUE_TIERS, hueLabel, swatchStyle } from '../utils/munsell'; +import { TreeEngine } from '../utils/tree-engine'; + +export interface ColorTreeStudioSignature { + Args: { + /* current selection, shown in the panel's picked chip */ + color?: string | null; + munsell?: string | null; + /* called (debounced) when the user picks a chip off the open atlas */ + onPick?: (hex: string, notation: string) => void; + /* suppresses the studio's own night-sky palette when a theme is linked */ + hasLinkedTheme?: boolean; + /* bounded height + panel closed by default, for field edit format */ + compact?: boolean; + }; + Element: HTMLElement; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + STUDIO — the room the specimen is held in + ═══════════════════════════════════════════════════════════════════════════ */ +export class ColorTreeStudio extends Component { + @tracked densityIdx = 1; + @tracked sliceMode = 0; // 0 off · 1 hue · 2 value + @tracked morphed = false; // false = Munsell tree · true = Itten sphere + @tracked paused = false; + @tracked slicePos = 50; + @tracked chromaPos = 100; + @tracked glowPos = 50; + @tracked contrastPos = 0; + @tracked toast = ''; + @tracked panelOpen = !this.args.compact; + @tracked miniOpen = true; + /* untracked mirror of miniOpen — setupScene must read this one, or the + autotracking modifier would tear down and rebuild the engine on every + toggle */ + miniPref = true; + @tracked turnPos = 15; + @tracked chipCount = 0; + @tracked soundOn = false; + @tracked morphState = 'tree measured'; + @tracked gyroOn = false; + @tracked gyroAvailable = + typeof window !== 'undefined' && + 'ontouchstart' in window && + !!window.DeviceOrientationEvent; + audioCtx?: AudioContext; + masterGain?: GainNode; + padLP?: BiquadFilterNode; + padOsc2?: OscillatorNode; + glisOsc?: OscillatorNode; + glisGain?: GainNode; + sliceOsc?: OscillatorNode; + sliceLP?: BiquadFilterNode; + sliceGain?: GainNode; + padTimer = 0; + morphTimer = 0; + ptrs = new Map(); + lastPinch: number | null = null; + holdTimer = 0; + holdFired = false; + layerKey = -1; + gLast: { a: number; b: number } | null = null; + engine?: TreeEngine; + + /* rapid picking shouldn't hammer the owner with saves — the last + selection within 300ms is the one that's kept */ + saveSelection = debounce((hex: string, notation: string) => { + this.args.onPick?.(hex, notation); + }, 300); + + /* ---------------------------- audio ---------------------------------- + The signature texture: a gallery at night. A low room tone of filtered + noise and three quiet sines in an open fifth — the pad of a place where + color is kept — playing the moment sound is on, in any view. The chroma + dial and the camera's approach open the pad's filter the way a skylight + opens a room; the contrast dial detunes the middle voice so complements + beat gently; the morph plays a slow glissando on the voxels' own clock; + the atlas carries a tenor voice that tracks the open page. */ + toggleSound = () => { + if (!this.audioCtx) { + try { + this.buildAudioGraph(); + } catch { + return; + } + } + this.soundOn = !this.soundOn; + let ctx = this.audioCtx!; + if (ctx.state === 'suspended') { + ctx.resume(); + } + this.masterGain!.gain.setTargetAtTime( + this.soundOn ? 0.5 : 0, + ctx.currentTime, + 0.6, + ); + this.updateSliceSound(); + }; + + buildAudioGraph() { + let Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + let ctx = new Ctor(); + this.audioCtx = ctx; + let master = ctx.createGain(); + master.gain.value = 0; + master.connect(ctx.destination); + this.masterGain = master; + + // room tone: a 2s loop of integrated noise, kept below 160 Hz + let len = ctx.sampleRate * 2; + let nbuf = ctx.createBuffer(1, len, ctx.sampleRate); + let data = nbuf.getChannelData(0); + let last = 0; + for (let i = 0; i < len; i++) { + let w = Math.random() * 2 - 1; + last = (last + 0.02 * w) / 1.02; + data[i] = last * 3.2; + } + let nsrc = ctx.createBufferSource(); + nsrc.buffer = nbuf; + nsrc.loop = true; + let nlp = ctx.createBiquadFilter(); + nlp.type = 'lowpass'; + nlp.frequency.value = 160; + nlp.Q.value = 0.4; + let ngain = ctx.createGain(); + ngain.gain.value = 0.1; + nsrc.connect(nlp); + nlp.connect(ngain); + ngain.connect(master); + nsrc.start(); + + // pad: three sines in an open fifth behind a slowly breathing filter + let padLP = ctx.createBiquadFilter(); + padLP.type = 'lowpass'; + padLP.frequency.value = 520; + padLP.Q.value = 0.6; + let padGain = ctx.createGain(); + padGain.gain.value = 0.11; + padLP.connect(padGain); + padGain.connect(master); + [110, 164.81, 220].forEach((f, i) => { + let o = ctx.createOscillator(); + o.type = 'sine'; + o.frequency.value = f; + let g = ctx.createGain(); + g.gain.value = i === 2 ? 0.5 : 0.85; + o.connect(g); + g.connect(padLP); + o.start(); + if (i === 1) { + this.padOsc2 = o; + } + }); + this.padLP = padLP; + let lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 0.07; + let lfoGain = ctx.createGain(); + lfoGain.gain.value = 110; + lfo.connect(lfoGain); + lfoGain.connect(padLP.frequency); + lfo.start(); + + // glissando voice for the morph + let glisOsc = ctx.createOscillator(); + glisOsc.type = 'sine'; + glisOsc.frequency.value = 329.63; + let glisGain = ctx.createGain(); + glisGain.gain.value = 0; + let glp = ctx.createBiquadFilter(); + glp.type = 'lowpass'; + glp.frequency.value = 900; + glisOsc.connect(glisGain); + glisGain.connect(glp); + glp.connect(master); + glisOsc.start(); + this.glisOsc = glisOsc; + this.glisGain = glisGain; + + // slice voice: a steady tenor that tracks the open atlas page — + // pitch climbs with the value disc, filter warms with the hue leaf + let sliceOsc = ctx.createOscillator(); + sliceOsc.type = 'sawtooth'; + sliceOsc.frequency.value = 220; + let sliceLP = ctx.createBiquadFilter(); + sliceLP.type = 'lowpass'; + sliceLP.frequency.value = 700; + sliceLP.Q.value = 3.2; + let sliceGain = ctx.createGain(); + sliceGain.gain.value = 0; + sliceOsc.connect(sliceLP); + sliceLP.connect(sliceGain); + sliceGain.connect(master); + sliceOsc.start(); + this.sliceOsc = sliceOsc; + this.sliceLP = sliceLP; + this.sliceGain = sliceGain; + + this.padTimer = window.setInterval(() => this.modulatePad(), 400); + } + + /* chroma opens the filter the way a skylight opens a room; approaching + the specimen brightens it further */ + modulatePad() { + if (!this.audioCtx || !this.soundOn || !this.padLP) { + return; + } + let near = this.engine + ? Math.max(0, Math.min(1, (70 - this.engine.dist) / 52)) + : 0; + let cut = 300 + (this.chromaPos / 100) * 700 + near * 500; + this.padLP.frequency.setTargetAtTime(cut, this.audioCtx.currentTime, 0.5); + } + + /* a slow E4↔B4 glide on the same clock the voxels travel by */ + playGliss(up: boolean) { + if (!this.audioCtx || !this.soundOn || !this.glisOsc || !this.glisGain) { + return; + } + let t = this.audioCtx.currentTime; + this.glisOsc.frequency.setValueAtTime(up ? 329.63 : 493.88, t); + this.glisOsc.frequency.setTargetAtTime(up ? 493.88 : 329.63, t, 0.5); + this.glisGain.gain.setTargetAtTime(0.06, t, 0.4); + this.glisGain.gain.setTargetAtTime(0, t + 1.0, 0.8); + } + + /* value mode: v 1..9 → ~2.3 octaves of pitch. Hue mode: warmth = + cos(angle around the wheel), warm leaves open the filter, cool ones + close it, with a slight pitch drift so each leaf has its own centre. + Silent whenever the atlas is closed or sound is off. */ + updateSliceSound() { + if (!this.audioCtx || !this.sliceOsc || !this.sliceLP || !this.sliceGain) { + return; + } + let t = this.audioCtx.currentTime; + if (!this.soundOn || this.sliceMode === 0) { + this.sliceGain.gain.setTargetAtTime(0, t, 0.35); + return; + } + if (this.sliceMode === 2) { + let v = 1 + Math.min(8, Math.floor((this.slicePos / 100) * 9)); + let f = 130 * Math.pow(2, ((v - 1) / 8) * 2.3); + this.sliceOsc.frequency.setTargetAtTime(f, t, 0.08); + this.sliceLP.frequency.setTargetAtTime(1100, t, 0.2); + this.sliceLP.Q.setTargetAtTime(2.4, t, 0.2); + this.sliceGain.gain.setTargetAtTime(0.055, t, 0.15); + } else { + let leaves = HUE_TIERS[this.densityIdx] / 2; + let p = this.slicePos / 100; + let j = Math.floor((((p % 1) + 1) % 1) * leaves) % leaves; + let angle = (j / leaves) * Math.PI * 2; + let warmth = Math.cos(angle); + let cutoff = 380 * Math.pow(2, (warmth + 1) * 1.6); + let pitch = 196 * Math.pow(2, warmth * 0.12); + this.sliceOsc.frequency.setTargetAtTime(pitch, t, 0.18); + this.sliceLP.frequency.setTargetAtTime(cutoff, t, 0.25); + this.sliceLP.Q.setTargetAtTime(warmth > 0 ? 4.0 : 6.5, t, 0.25); + this.sliceGain.gain.setTargetAtTime(0.048, t, 0.25); + } + } + + /* a small acknowledgment when a color is taken: two quick sine notes a + fifth apart, soft attack, fast decay — a glass tap */ + copyChime() { + if (!this.audioCtx || !this.soundOn || !this.masterGain) { + return; + } + let ctx = this.audioCtx; + let master = this.masterGain; + let t = ctx.currentTime; + for (let [f, d] of [ + [1318.51, 0], + [1975.53, 0.07], + ]) { + let o = ctx.createOscillator(); + o.type = 'sine'; + o.frequency.value = f; + let og = ctx.createGain(); + og.gain.value = 0; + o.connect(og); + og.connect(master); + og.gain.setValueAtTime(0, t + d); + og.gain.linearRampToValueAtTime(0.09, t + d + 0.012); + og.gain.setTargetAtTime(0, t + d + 0.04, 0.09); + o.start(t + d); + o.stop(t + d + 0.7); + } + } + + dragging = false; + lastX = 0; + lastY = 0; + moved = 0; + toastTimer = 0; + @tracked pickFx: { id: number; x: number; y: number; hex: string } | null = + null; + pickFxId = 0; + pickFxTimer = 0; + + setupScene = modifier((canvas: HTMLCanvasElement) => { + let engine = new TreeEngine(canvas); + engine.showMini = this.miniPref; + this.engine = engine; + this.chipCount = engine.arrays.cfr.length; + let onResize = () => engine.resize(); + window.addEventListener('resize', onResize); + let ro = new ResizeObserver(onResize); + ro.observe(canvas); + return () => { + window.removeEventListener('resize', onResize); + window.removeEventListener('deviceorientation', this.onGyro); + clearTimeout(this.holdTimer); + clearTimeout(this.morphTimer); + clearTimeout(this.pickFxTimer); + clearTimeout(this.toastTimer); + this.saveSelection.flush(); + clearInterval(this.padTimer); + this.audioCtx?.close?.(); + this.audioCtx = undefined; + this.masterGain = undefined; + this.padLP = undefined; + this.padOsc2 = undefined; + this.glisOsc = undefined; + this.glisGain = undefined; + this.sliceOsc = undefined; + this.sliceLP = undefined; + this.sliceGain = undefined; + this.soundOn = false; + ro.disconnect(); + engine.dispose(); + this.engine = undefined; + }; + }); + + showToast = (msg: string, ms = 1800) => { + this.toast = msg; + clearTimeout(this.toastTimer); + this.toastTimer = window.setTimeout(() => (this.toast = ''), ms); + }; + + dismissToast = () => { + clearTimeout(this.toastTimer); + this.toast = ''; + }; + + onPointerDown = (evt: Event) => { + let e = evt as PointerEvent; + this.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); + this.dragging = true; + this.moved = 0; + this.lastX = e.clientX; + this.lastY = e.clientY; + this.lastPinch = null; + this.holdFired = false; + clearTimeout(this.holdTimer); + if (this.sliceMode !== 0) { + // hold-to-copy: press and hold any point on the open page for 450ms + let cx = e.clientX; + let cy = e.clientY; + this.holdTimer = window.setTimeout(() => { + if (!this.dragging || this.moved > 6) { + return; + } + this.holdFired = true; + this.pickAt(cx, cy); + }, 450); + } + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + }; + + onPointerMove = (evt: Event) => { + let e = evt as PointerEvent; + if (!this.dragging || !this.engine) { + return; + } + if (this.ptrs.has(e.pointerId)) { + this.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); + } + if (this.ptrs.size >= 2) { + // pinch to approach: two fingers change the camera's distance + let [p1, p2] = [...this.ptrs.values()]; + let d = Math.hypot(p1.x - p2.x, p1.y - p2.y); + if (this.lastPinch != null) { + this.engine.zoom((this.lastPinch - d) * 0.12); + } + this.lastPinch = d; + this.moved += 10; + return; + } + let dx = e.clientX - this.lastX; + let dy = e.clientY - this.lastY; + this.moved += Math.abs(dx) + Math.abs(dy); + this.lastX = e.clientX; + this.lastY = e.clientY; + if (this.sliceMode !== 0) { + // while reading the atlas, the drag scrubs pages instead of tumbling + let p = this.slicePos / 100; + if (this.sliceMode === 2) { + p = Math.max(0, Math.min(1, p - dy / (window.innerHeight * 0.8))); + } else { + p = (((p + dx / window.innerWidth) % 1) + 1) % 1; + } + this.slicePos = p * 100; + this.applyScan(); + return; + } + this.engine.tumble(dx, dy); + }; + + onPointerUp = (evt: Event) => { + let e = evt as PointerEvent; + clearTimeout(this.holdTimer); + this.ptrs.delete(e.pointerId); + if (this.ptrs.size > 0) { + this.lastPinch = null; + return; + } + let wasDrag = this.moved > 6; + this.dragging = false; + if (wasDrag || !this.engine || this.holdFired) { + this.holdFired = false; + return; + } + this.pickAt(e.clientX, e.clientY); + }; + + pickAt(cx: number, cy: number) { + if (!this.engine) { + return; + } + let picked = this.engine.pickHex(cx, cy); + if (!picked) { + return; + } + this.spawnPickFx(cx, cy, picked.hex); + this.saveSelection(picked.hex, picked.notation); + try { + navigator.clipboard?.writeText(picked.hex); + this.copyChime(); + this.showToast(`${picked.hex} copied · ${picked.notation}`); + } catch { + this.showToast(`${picked.hex} · ${picked.notation}`); + } + } + + /* a brief pulse of the taken color, blooming from the picked spot — + the visual half of the copy acknowledgment (the chime is the other) */ + spawnPickFx(cx: number, cy: number, hex: string) { + let rect = this.engine?.canvas.getBoundingClientRect(); + if (!rect) { + return; + } + this.pickFx = { + id: this.pickFxId++, + x: cx - rect.left, + y: cy - rect.top, + hex, + }; + clearTimeout(this.pickFxTimer); + this.pickFxTimer = window.setTimeout(() => (this.pickFx = null), 750); + } + + get pickFxList() { + return this.pickFx ? [this.pickFx] : []; + } + + /* hex comes from our own chipHex/rgbHex, so it's always a #rrggbb + literal — never an unvalidated string interpolated into a style */ + pickFxStyle = (fx: { x: number; y: number; hex: string }) => + htmlSafe(`left: ${fx.x}px; top: ${fx.y}px; --fx-color: ${fx.hex}`); + + onWheel = (evt: Event) => { + let e = evt as WheelEvent; + e.preventDefault(); + this.engine?.zoom(e.deltaY * 0.03); + }; + + onDblClick = () => { + this.engine?.refit(); + }; + + setDensity = (idx: number) => { + this.densityIdx = idx; + this.engine?.setDensity(idx); + if (this.engine) { + this.chipCount = this.engine.arrays.cfr.length; + } + this.updateSliceSound(); + }; + + togglePanel = () => { + this.panelOpen = !this.panelOpen; + }; + + toggleMini = () => { + this.miniOpen = !this.miniOpen; + this.miniPref = this.miniOpen; + if (this.engine) { + this.engine.showMini = this.miniOpen; + } + }; + + onTurn = (e: Event) => { + this.turnPos = Number((e.target as HTMLInputElement).value); + if (this.engine) { + this.engine.baseSpin = (this.turnPos / 100) * 0.01; + } + }; + + /* the atlas isn't a separate view — sliceMode alone decides whether the + page is open (>0) and which cut it shows, so scan segment, the single + cycling button, and the drag/scrub all just set this one number */ + setSlice = (mode: number) => { + let changed = mode !== this.sliceMode; + this.sliceMode = mode; + this.layerKey = -1; + if (this.engine) { + this.engine.sliceTarget = mode; + this.engine.chartTarget = mode > 0 ? 1 : 0; + } + this.applyScan(); + // announce every view change — Munsell's axes aren't obvious, and + // neither is the scrub gesture (hue pages turn ⟷, value cuts move ↕) + if (changed && mode === 1) { + this.showToast('HUE · a vertical leaf — drag ⟷ to turn the pages', 3000); + } else if (changed && mode === 2) { + this.showToast( + 'VALUE · a horizontal cut — drag ↕ to move through lightness', + 3000, + ); + } else if (changed && mode === 0) { + this.showToast('atlas closed · the whole solid, drag to tumble', 3000); + } + }; + + get scanLabel(): string { + return ( + ['OPEN THE ATLAS', 'SLICE BY VALUE', 'CLOSE THE ATLAS'][this.sliceMode] ?? + 'OPEN THE ATLAS' + ); + } + + cycleScan = () => { + this.setSlice((this.sliceMode + 1) % 3); + }; + + /* the same morph dial does double duty: tree ↔ sphere when the atlas + is closed, Munsell's measured chroma ↔ the ideal linear grid when + it's open — one continuous knob instead of a second toggle */ + get morphLabel(): string { + if (this.sliceMode !== 0) { + return this.morphed ? 'MUNSELL SCALE' : 'LINEAR SCALE'; + } + return this.morphed ? 'TO MUNSELL' : 'TO ITTEN'; + } + + toggleMorph = () => { + this.morphed = !this.morphed; + if (this.engine) { + this.engine.morphTarget = this.morphed ? 1 : 0; + } + this.playGliss(this.morphed); + this.morphState = this.morphed ? 'dreaming…' : 'waking…'; + clearTimeout(this.morphTimer); + this.morphTimer = window.setTimeout(() => { + this.morphState = this.morphed ? 'sphere dreamt' : 'tree measured'; + }, 1400); + }; + + toggleGyro = async () => { + if (this.gyroOn) { + this.gyroOn = false; + window.removeEventListener('deviceorientation', this.onGyro); + return; + } + try { + let doe = DeviceOrientationEvent as unknown as { + requestPermission?: () => Promise; + }; + if (typeof doe.requestPermission === 'function') { + let res = await doe.requestPermission(); + if (res !== 'granted') { + this.showToast('gyro permission declined'); + return; + } + } + this.gLast = null; + window.addEventListener('deviceorientation', this.onGyro); + this.gyroOn = true; + } catch { + this.showToast('gyro unavailable'); + } + }; + + onGyro = (e: DeviceOrientationEvent) => { + if (e.alpha == null || e.beta == null || !this.engine) { + return; + } + if (this.gLast) { + this.engine.tumble( + (e.alpha - this.gLast.a) * 2.2, + (e.beta - this.gLast.b) * 2.2, + ); + } + this.gLast = { a: e.alpha, b: e.beta }; + }; + + togglePause = () => { + this.paused = !this.paused; + if (this.engine) { + this.engine.paused = this.paused; + } + }; + + /* the scan flicks layer by layer, like turning pages — never a smear */ + snapScan(): { snapped: number; key: number } { + let p = this.slicePos / 100; + if (this.sliceMode === 2) { + let v = 1 + Math.min(8, Math.floor(p * 9)); + return { snapped: v / 10, key: 1000 + v }; + } + let leaves = HUE_TIERS[this.densityIdx] / 2; + let j = Math.floor((((p % 1) + 1) % 1) * leaves) % leaves; + return { snapped: (j * 2) / HUE_TIERS[this.densityIdx], key: j }; + } + + /* one gesture, one truth: slider moves and page-scrubs both land here. + There's no separate atlas state to sync — sliceTarget/uSlicePos on the + engine ARE what decides both the 3D cut and the flattened page */ + applyScan() { + let { snapped, key } = this.snapScan(); + if (this.engine) { + this.engine.uniforms.uSlicePos.value = snapped; + this.engine.buildGrid(this.sliceMode, snapped); + } + let layerChanged = key !== this.layerKey; + this.layerKey = key; + if (layerChanged) { + this.updateSliceSound(); + } + } + + onSlicePos = (e: Event) => { + this.slicePos = Number((e.target as HTMLInputElement).value); + this.applyScan(); + }; + + onChroma = (e: Event) => { + this.chromaPos = Number((e.target as HTMLInputElement).value); + if (this.engine) { + this.engine.uniforms.uChroma.value = this.chromaPos / 100; + } + }; + + onGlow = (e: Event) => { + this.glowPos = Number((e.target as HTMLInputElement).value); + if (this.engine) { + this.engine.uniforms.uGlow.value = this.glowPos / 100; + } + }; + + onContrast = (e: Event) => { + this.contrastPos = Number((e.target as HTMLInputElement).value); + if (this.engine) { + this.engine.uniforms.uContrast.value = this.contrastPos / 100; + } + // contrast detunes the pad's middle voice so complements beat, gently + if (this.audioCtx && this.padOsc2) { + this.padOsc2.detune.setTargetAtTime( + (this.contrastPos / 100) * 9, + this.audioCtx.currentTime, + 0.3, + ); + } + }; + + /* what the panel reports: the page's cut when it's open, otherwise the + state of the 3D solid */ + get statsCaption(): string { + let scanOpen = this.sliceMode !== 0; + let state = scanOpen + ? this.morphed + ? 'linear field · every step' + : "munsell scale · the eye's selection" + : this.morphState; + let cut = ''; + if (scanOpen && this.sliceMode === 1) { + let h = this.snapScan().snapped / 2; + cut = ` · vertical leaf ${hueLabel(h)} / ${hueLabel((h + 0.5) % 1)}`; + } else if (scanOpen && this.sliceMode === 2) { + cut = ` · horizontal cut at value ${this.snapScan().key - 1000}`; + } + return `${state}${cut}`; + } + + get atlasIsOpen() { + return this.sliceMode !== 0; + } + + /* the hint speaks to the view that's actually on stage: gestures for + the 3D solid, page-reading for the open atlas */ + get hintText(): string { + if (this.sliceMode === 1) { + return 'drag ⟷ to turn the hue pages · hold a spot on the open page to copy its hex'; + } + if (this.sliceMode === 2) { + return 'drag ↕ to move through the value cuts · hold a spot on the open page to copy its hex'; + } + return 'drag to tumble · scroll to approach · double-click to refit'; + } + + get selectedHex() { + return this.args.color ?? ''; + } + + get selectedNotation() { + return this.args.munsell ?? ''; + } + + +} diff --git a/5ee9e6-color-tree/example/ColorTreeFieldExample/bc3ae52b-904d-46b5-9913-9f703f7cac04.json b/5ee9e6-color-tree/example/ColorTreeFieldExample/bc3ae52b-904d-46b5-9913-9f703f7cac04.json new file mode 100644 index 00000000..a13b5ffe --- /dev/null +++ b/5ee9e6-color-tree/example/ColorTreeFieldExample/bc3ae52b-904d-46b5-9913-9f703f7cac04.json @@ -0,0 +1,25 @@ +{ + "data": { + "type": "card", + "attributes": { + "pick": { + "hex": null, + "munsell": null + }, + "title": "Color Tree Field — fresh", + "description": "A fresh instance: open the atlas in the studio below and hold a spot on the page to make your first pick.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../color-tree-field-playground-example", + "name": "ColorTreeFieldExample" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/example/ColorTreeFieldExample/dc3c493e-5883-4f74-b6e4-63d2b75795c6.json b/5ee9e6-color-tree/example/ColorTreeFieldExample/dc3c493e-5883-4f74-b6e4-63d2b75795c6.json new file mode 100644 index 00000000..8d7aac67 --- /dev/null +++ b/5ee9e6-color-tree/example/ColorTreeFieldExample/dc3c493e-5883-4f74-b6e4-63d2b75795c6.json @@ -0,0 +1,25 @@ +{ + "data": { + "type": "card", + "attributes": { + "pick": { + "hex": "#2e8b6a", + "munsell": "5G 5/8" + }, + "title": "Color Tree Field", + "description": "A Munsell color pick as a field: the edit format is the full 3D studio, so any card gets perceptual hue/value/chroma picking with one contains().", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../color-tree-field-playground-example", + "name": "ColorTreeFieldExample" + } + } + } +} \ No newline at end of file diff --git a/5ee9e6-color-tree/example/color-tree-field-playground-example.gts b/5ee9e6-color-tree/example/color-tree-field-playground-example.gts new file mode 100644 index 00000000..bb89ac1c --- /dev/null +++ b/5ee9e6-color-tree/example/color-tree-field-playground-example.gts @@ -0,0 +1,137 @@ +// Playground example for ColorTreeField — shows the field the way a card +// author will meet it: the 3D studio as the edit format, the compact +// embedded/atom read-outs, and the one-line `contains()` to adopt it. +import { + CardDef, + Component, + contains, + field, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import CodeSnippet from '../../components/code-snippet'; +import { ColorTreeField } from '../color-tree-field'; + +const usageCode = `import { ColorTreeField } from '@cardstack/catalog/5ee9e6-color-tree/color-tree-field'; + +export class MyCard extends CardDef { + @field accentColor = contains(ColorTreeField); +}`; + +export class ColorTreeFieldExample extends CardDef { + static displayName = 'Color Tree Field Example'; + + @field pick = contains(ColorTreeField); + @field title = contains(StringField); + @field description = contains(StringField); + + static isolated = class Isolated extends Component { + + }; +} diff --git a/5ee9e6-color-tree/utils/munsell.gts b/5ee9e6-color-tree/utils/munsell.gts new file mode 100644 index 00000000..95fc61f8 --- /dev/null +++ b/5ee9e6-color-tree/utils/munsell.gts @@ -0,0 +1,229 @@ +// ============================================================================= +// Munsell color math — the measured data and pure functions behind the tree. +// +// Munsell space is perceptual, not mathematical: each of the 10 principal +// hues has its own maximum chroma (C_PEAK) reached at its own value level +// (V_PEAK). Yellow crowns near value 8.5, purple-blue near value 3. The +// quadratic falloff from each hue's peak gives the solid its honest, +// lopsided canopy — it refuses to be a sphere. +// ============================================================================= +import { htmlSafe } from '@ember/template'; + +/* ═══════════════════════════════════════════════════════════════════════════ + MUNSELL DATA — measured, lumpy, human + ═══════════════════════════════════════════════════════════════════════════ */ +export const TAU = Math.PI * 2; +export const V_STEP = 2.2; // world units per value step +export const C_SCALE = 0.9; // world units per chroma step +export const CENTER_V = 5; // value 5 sits at y = 0 +export const SPHERE_R = 9; // Itten's idealized sphere radius + +export const HUE_NAMES = [ + '5R', + '5YR', + '5Y', + '5GY', + '5G', + '5BG', + '5B', + '5PB', + '5P', + '5RP', +]; +// peak chroma each principal hue can reach (approx. Munsell renotation) +export const C_PEAK = [14, 13, 13, 11, 11, 9, 9, 13, 12, 12]; +// the value level at which that peak chroma occurs +export const V_PEAK = [5, 6, 8.5, 7.5, 6, 5.5, 4.5, 3, 3.5, 4.5]; +// display hue angle (HSL degrees) for each principal hue +export const HUE_DEG = [357, 32, 58, 95, 145, 182, 212, 255, 288, 332]; + +export const HUE_TIERS = [10, 20, 40]; +export const EDGE_BASE = [1.5, 1.15, 0.95]; // denser lattices, smaller cubes + +export function lerpWrap(arr: number[], h: number): number { + let x = h * 10; + let i = Math.floor(x) % 10; + let j = (i + 1) % 10; + let f = x - Math.floor(x); + f = f * f * (3 - 2 * f); + return arr[i] + (arr[j] - arr[i]) * f; +} + +export function lerpWrapDeg(h: number): number { + let x = h * 10; + let i = Math.floor(x) % 10; + let j = (i + 1) % 10; + let f = x - Math.floor(x); + f = f * f * (3 - 2 * f); + let a = HUE_DEG[i]; + let b = HUE_DEG[j]; + if (b - a > 180) { + b -= 360; + } else if (a - b > 180) { + b += 360; + } + return (((a + (b - a) * f) % 360) + 360) % 360; +} + +export function maxChroma(h: number, v: number): number { + let cp = lerpWrap(C_PEAK, h); + let vp = lerpWrap(V_PEAK, h); + let spread = v > vp ? 10.6 - vp : vp + 0.9; + let t = (v - vp) / spread; + return Math.max(0, cp * (1 - t * t)); +} + +export function hslToRgb( + h: number, + s: number, + l: number, +): [number, number, number] { + h = (((h % 360) + 360) % 360) / 360; + let q = l < 0.5 ? l * (1 + s) : l + s - l * s; + let p = 2 * l - q; + let hk = (t: number) => { + t = ((t % 1) + 1) % 1; + if (t < 1 / 6) { + return p + (q - p) * 6 * t; + } + if (t < 1 / 2) { + return q; + } + if (t < 2 / 3) { + return p + (q - p) * (2 / 3 - t) * 6; + } + return p; + }; + return [hk(h + 1 / 3), hk(h), hk(h - 1 / 3)]; +} + +export function chipColor( + h: number, + v: number, + c: number, +): [number, number, number] { + if (c < 0.5) { + let l = 0.07 + 0.86 * (v / 10); + return [l * 0.92, l * 0.96, l]; + } + let deg = lerpWrapDeg(h); + let s = Math.min(1, (c / 13) * 1.12); + let l = 0.07 + 0.86 * (v / 10); + return hslToRgb(deg, s, l); +} + +export function toHex2(x: number): string { + let v = Math.max(0, Math.min(255, Math.round(x * 255))); + return v.toString(16).padStart(2, '0'); +} + +export function chipHex(h: number, v: number, c: number): string { + let [r, g, b] = chipColor(h, v, c); + return `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`; +} + +export function rgbHex(r: number, g: number, b: number): string { + return `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`; +} + +export function swatchStyle(color: string | undefined | null) { + return htmlSafe(`background-color: ${color || '#39505e'}`); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + ATLAS — the third address: the printed page. Unlike the tree/sphere + addresses (baked once per voxel into instanced attributes), the page + is not a separate data structure — the same voxel mesh is laid flat by + the vertex shader itself (see VOXEL_VSH's uChart branch), so there is + nothing here to build or gap-fill on the CPU. + ═══════════════════════════════════════════════════════════════════════════ */ +export function hueLabel(h: number): string { + return HUE_NAMES[Math.round(h * 10) % 10]; +} + +export function munsellNotation(h: number, v: number, c: number): string { + if (c < 0.5) { + return `N ${v}/`; + } + let idx = Math.round(h * 10) % 10; + return `${HUE_NAMES[idx]} ${v}/${Math.round(c)}`; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + LATTICE — every voxel knows two addresses: the tree and the sphere + ═══════════════════════════════════════════════════════════════════════════ */ +export interface ChipArrays { + tree: number[]; + sphere: number[]; + cols: number[]; + cfr: number[]; + ang: number[]; + seed: number[]; + val: number[]; + chr: number[]; + edg: number[]; + hues: number[]; +} + +export function buildArrays(hueCount: number, densityIdx: number): ChipArrays { + let base = EDGE_BASE[densityIdx]; + let a: ChipArrays = { + tree: [], + sphere: [], + cols: [], + cfr: [], + ang: [], + seed: [], + val: [], + chr: [], + edg: [], + hues: [], + }; + let push = (h: number, v: number, c: number) => { + let theta = h * TAU; + let mc = Math.max(maxChroma(h, v), 0.001); + let f = Math.min(1, c / mc); + let r = c * C_SCALE; + a.tree.push( + Math.cos(theta) * r, + (v - CENTER_V) * V_STEP, + Math.sin(theta) * r, + ); + // Itten's sphere: value maps to latitude, chroma fraction to longitude reach + let ct = v / 5 - 1; + let st = Math.sqrt(Math.max(0, 1 - ct * ct)); + let rs = SPHERE_R * st * f; + a.sphere.push(Math.cos(theta) * rs, SPHERE_R * ct, Math.sin(theta) * rs); + let rgb = chipColor(h, v, c); + a.cols.push(rgb[0], rgb[1], rgb[2]); + a.cfr.push(c < 0.5 ? 0 : f); + a.ang.push(theta); + a.seed.push(Math.random()); + a.val.push(v); + a.chr.push(c); + a.hues.push(h); + // each cube solves its own edge so neighbors never touch: + // tangential room at radius r is r·2π/H, radial room is one chroma step + let e: number; + if (c < 0.5) { + e = Math.min(base * 0.85, 0.82 * V_STEP); + } else { + let tang = (r * TAU) / hueCount; + e = Math.min(base, 0.82 * tang, 0.82 * 2 * C_SCALE); + } + a.edg.push(e); + }; + for (let hi = 0; hi < hueCount; hi++) { + let h = hi / hueCount; + for (let v = 1; v <= 9; v++) { + let mc = maxChroma(h, v); + for (let c = 2; c <= mc + 0.001; c += 2) { + push(h, v, c); + } + } + } + for (let v = 0; v <= 10; v++) { + push(0, v, 0); // the gray trunk + } + return a; +} diff --git a/5ee9e6-color-tree/utils/shaders.gts b/5ee9e6-color-tree/utils/shaders.gts new file mode 100644 index 00000000..c927dd97 --- /dev/null +++ b/5ee9e6-color-tree/utils/shaders.gts @@ -0,0 +1,323 @@ +// ============================================================================= +// GLSL for the color tree's four render passes: +// VOXEL — the instanced cubes; every voxel knows three addresses (the +// measured tree, Itten's idealized sphere, and the flattened +// atlas page) and morphs between them in the vertex shader +// HALO — one soft additive point of light behind each voxel (3D only) +// GRID — the LINEAR SCALE page's complete conventional grid, synthetic +// swatches at every saturation × lightness step +// RING — markers showing where the measured chips actually sit inside +// that ideal field +// ============================================================================= + +/* ═══════════════════════════════════════════════════════════════════════════ + SHADERS — morph, slice, light + ═══════════════════════════════════════════════════════════════════════════ */ +export const VOXEL_VSH = ` +attribute vec3 aTree; +attribute vec3 aSphere; +attribute vec3 aColor; +attribute float aCFrac; +attribute float aAngle; +attribute float aSeed; +attribute float aValue; +attribute float aChroma; +attribute float aEdge; +uniform float uTime, uMorph, uChart, uChroma, uContrast, uAxis, uGlow, uMini; +uniform float uSliceMode, uSlicePos, uSliceHalf, uHueCount, uCellHue, uCellVal; +uniform vec3 uCamR, uCamU, uCamF, uAnchor; +varying vec3 vColor; +varying vec3 vNormal; +varying float vPage; + +float sliceGate(float angle, float value, float cfrac){ + float s = 1.0; + if (uSliceMode > 0.5 && uSliceMode < 1.5){ + float phi = uSlicePos * 3.14159265; + float dA = angle - phi; + dA = abs(atan(sin(dA), cos(dA))); + dA = min(dA, 3.14159265 - dA); /* the leaf holds a hue and its complement */ + s = 1.0 - smoothstep(uSliceHalf * 0.55, uSliceHalf * 0.95, dA); + s = max(s, step(cfrac, 0.001)); /* the trunk belongs to every leaf */ + } else if (uSliceMode > 1.5){ + float dv = abs(value - uSlicePos * 10.0); + s = (1.0 - smoothstep(0.4, 0.75, dv)) * step(0.001, cfrac); + } + return s; +} + +void main(){ + float gate = sliceGate(aAngle, aValue, aCFrac); + /* chroma reveal: outer chips fade first as the dial closes */ + float vis = 1.0 - smoothstep(uChroma + 0.02, uChroma + 0.14, aCFrac); + vis = max(vis, step(aCFrac, 0.001)); + /* directional pigment light keyed to the tumble axis */ + float d = cos(aAngle - uAxis); + float lit = mix(1.0, 0.06 + 0.94 * smoothstep(0.15, 0.92, d * 0.5 + 0.5), + uContrast * step(0.001, aCFrac)); + /* the quiet shimmer of pigment under lamplight */ + float sh = 0.94 + 0.06 * sin(uTime * 1.4 + aSeed * 43.0); + vColor = aColor * lit * sh * (0.9 + 0.25 * uGlow); + vNormal = normalMatrix * normal; + + /* as the page opens, cubes outside the current cut shrink away instead + of staying full-size in their old 3D spot — otherwise the untouched + tree would keep floating in front of the flattened page */ + float ch = smoothstep(0.02, 1.0, uChart); + float open2 = smoothstep(0.0, 0.35, ch); + /* the scout miniature (uMini) shows the whole solid instead: excluded + voxels stay at half size and dimmed, the current cut lit bright */ + float keep = mix(mix(1.0, gate, open2), mix(0.5, 1.0, gate), uMini); + vColor *= mix(max(gate, 1.0 - open2), 1.0, uMini) + * mix(1.0, mix(0.14, 1.0, gate) * ch, uMini); + + /* address one: the solid, tumbling */ + float edge = aEdge * vis * keep; + vec3 posT = mix(aTree, aSphere, uMorph); + vec3 pv = position * edge; + vec3 cW = (modelMatrix * vec4(posT, 1.0)).xyz; + vec3 oW = mat3(modelMatrix) * pv; + + /* address two: the printed page, hanging square to the eye. in LINEAR + SCALE each chip glides to its true position in the linear field (its + plain saturation) instead of its measured chroma */ + float sLin = min(1.0, aChroma / 13.0 * 1.12); + float colU = mix(aChroma * 0.5, sLin * 8.0, uMorph); + vec2 g; + float cell; + if (uSliceMode > 1.5){ + cell = uCellVal; + float hIdx = aAngle / 6.2831853 * uHueCount; + g = vec2((hIdx - uHueCount * 0.5 + 0.5) * cell, (colU - 4.5) * cell); + } else { + cell = uCellHue; + float phi = uSlicePos * 3.14159265; + float dA = atan(sin(aAngle - phi), cos(aAngle - phi)); + float side = step(abs(dA), 1.5707963) * 2.0 - 1.0; + g = vec2(side * colU * cell, (aValue - 5.0) * cell); + } + vec3 chartC = uAnchor + uCamR * g.x + uCamU * g.y; + /* in LINEAR SCALE each chip glides to its true saturation position — + which sits between grid rows — then yields the square to the GRID + pass (shrinking to zero as uMorph·ch reaches 1), leaving only its + dark ring to mark where the measured chip stands in the ideal field */ + vec3 pvC = position * vec3(cell * 0.86, cell * 0.86, cell * 0.12) * vis + * (1.0 - uMorph * ch); + vec3 oC = uCamR * pvC.x + uCamU * pvC.y + uCamF * pvC.z; + + float b = ch * gate * (1.0 - uMini); + vPage = b; + vec3 P = mix(cW + oW, chartC + oC, b); + gl_Position = projectionMatrix * viewMatrix * vec4(P, 1.0); +} +`; + +export const VOXEL_FSH = ` +precision highp float; +varying vec3 vColor; +varying vec3 vNormal; +varying float vPage; +void main(){ + vec3 n = normalize(vNormal); + float lamb = 0.72 + 0.28 * max(dot(n, normalize(vec3(0.4, 0.8, 0.6))), 0.0); + /* zero on the open page, so the atlas shows the exact hex */ + float flat_ = mix(lamb, 1.0, vPage); + gl_FragColor = vec4(vColor * flat_, 1.0); +} +`; + +/* the halo: a soft additive breath behind every voxel, driven by the + glow dial — pigment remembered as light */ +export const HALO_VSH = ` +attribute vec3 aSphere; +attribute vec3 aColor; +attribute float aCFrac; +attribute float aAngle; +attribute float aValue; +attribute float aChroma; +attribute float aEdge; +uniform float uMorph, uChart, uGlow, uChroma, uMini; +uniform float uSliceMode, uSlicePos, uSliceHalf, uHueCount, uCellHue, uCellVal; +uniform vec3 uCamR, uCamU, uAnchor; +varying vec3 vColor; +varying float vA; + +float sliceGate(float angle, float value, float cfrac){ + float s = 1.0; + if (uSliceMode > 0.5 && uSliceMode < 1.5){ + float phi = uSlicePos * 3.14159265; + float dA = angle - phi; + dA = abs(atan(sin(dA), cos(dA))); + dA = min(dA, 3.14159265 - dA); + s = 1.0 - smoothstep(uSliceHalf * 0.55, uSliceHalf * 0.95, dA); + s = max(s, step(cfrac, 0.001)); + } else if (uSliceMode > 1.5){ + float dv = abs(value - uSlicePos * 10.0); + s = (1.0 - smoothstep(0.4, 0.75, dv)) * step(0.001, cfrac); + } + return s; +} + +void main(){ + float gate = sliceGate(aAngle, aValue, aCFrac); + float vis = 1.0 - smoothstep(uChroma + 0.02, uChroma + 0.14, aCFrac); + vis = max(vis, step(aCFrac, 0.001)); + vColor = aColor; + + /* position is aTree — halo morphs the same tree/sphere/chart addresses + the voxel mesh does, so it never drifts from the cubes it glows behind */ + vec3 posT = mix(position, aSphere, uMorph); + vec3 worldT = (modelMatrix * vec4(posT, 1.0)).xyz; + + float sLin = min(1.0, aChroma / 13.0 * 1.12); + float colU = mix(aChroma * 0.5, sLin * 8.0, uMorph); + vec2 g; + float cell; + if (uSliceMode > 1.5){ + cell = uCellVal; + float hIdx = aAngle / 6.2831853 * uHueCount; + g = vec2((hIdx - uHueCount * 0.5 + 0.5) * cell, (colU - 4.5) * cell); + } else { + cell = uCellHue; + float phi = uSlicePos * 3.14159265; + float dA = atan(sin(aAngle - phi), cos(aAngle - phi)); + float side = step(abs(dA), 1.5707963) * 2.0 - 1.0; + g = vec2(side * colU * cell, (aValue - 5.0) * cell); + } + vec3 chartPos = uAnchor + uCamR * g.x + uCamU * g.y; + + float ch = smoothstep(0.02, 1.0, uChart); + float b = ch * gate; + vec3 P = mix(worldT, chartPos, b); + + /* the halo is a 3D-only effect — it fades out completely as the page + opens (and stays off in the scout miniature), so a flattened chip + never carries its own glow-dot with it */ + vA = 0.16 * uGlow * gate * vis * (1.0 - ch) * (1.0 - uMini); + vec4 mv = viewMatrix * vec4(P, 1.0); + gl_PointSize = aEdge * uGlow * 220.0 / max(1.0, -mv.z); + gl_Position = projectionMatrix * mv; +} +`; + +export const HALO_FSH = ` +precision highp float; +varying vec3 vColor; +varying float vA; +void main(){ + vec2 d = gl_PointCoord - 0.5; + float a = smoothstep(0.5, 0.0, length(d)) * vA; + gl_FragColor = vec4(vColor, a); +} +`; + +/* the linear field, and the marks. In LINEAR SCALE the page becomes a + complete conventional grid: even saturation steps across, even + lightness steps down, every cell filled — synthetic swatches, not + voxels. Rings then mark where the measured chips actually sit inside + that ideal field: the perceptual palette is a selection, not a grid. */ +export const GRID_VSH = ` +attribute vec3 aCol; +uniform float uChart, uMorph, uScale, uSliceMode, uCellHue, uCellVal, uMini; +uniform vec3 uCamR, uCamU, uAnchor; +varying vec3 vC; +void main(){ + float cell = uSliceMode > 1.5 ? uCellVal : uCellHue; + float lin = uMorph * uMorph * (3.0 - 2.0 * uMorph); + float ch = smoothstep(0.02, 1.0, uChart); + float vis = ch * lin; + vC = aCol; + vec3 P = uAnchor + uCamR * (position.x * cell) + uCamU * (position.y * cell); + vec4 mv = viewMatrix * vec4(P, 1.0); + gl_Position = projectionMatrix * mv; + /* gate visibility here, not with a fragment-shader discard — combining + discard with reading the vC varying left the whole pass invisible on + this driver, so an all-or-nothing point size sidesteps it entirely. + the page never appears in the scout miniature (uMini). */ + gl_PointSize = (vis < 0.5 || uMini > 0.5) + ? 0.0 + : min(cell * 0.86 * uScale / max(-mv.z, 0.5), 200.0); +} +`; +export const GRID_FSH = ` +precision highp float; +varying vec3 vC; +void main(){ + vec2 p = gl_PointCoord * 2.0 - 1.0; + float mq = max(abs(p.x), abs(p.y)); + /* the outer 8% of the sprite is the gap between cells — cut it with + alpha rather than discard (discard + a color varying breaks this + pass on some drivers), so the rim never shows as a dark border */ + float inside = 1.0 - step(0.92, mq); + float edge = 1.0 - smoothstep(0.78, 0.92, mq) * 0.4; + gl_FragColor = vec4(vC * edge, inside); +} +`; + +export const RING_VSH = ` +attribute vec3 aSphere; +attribute float aCFrac; +attribute float aAngle; +attribute float aValue; +attribute float aChroma; +uniform float uChart, uMorph, uScale, uMini; +uniform float uSliceMode, uSlicePos, uSliceHalf, uHueCount, uCellHue, uCellVal; +uniform vec3 uCamR, uCamU, uAnchor; +varying float vA; + +float sliceGate(float angle, float value, float cfrac){ + float s = 1.0; + if (uSliceMode > 0.5 && uSliceMode < 1.5){ + float phi = uSlicePos * 3.14159265; + float dA = angle - phi; + dA = abs(atan(sin(dA), cos(dA))); + dA = min(dA, 3.14159265 - dA); + s = 1.0 - smoothstep(uSliceHalf * 0.55, uSliceHalf * 0.95, dA); + s = max(s, step(cfrac, 0.001)); + } else if (uSliceMode > 1.5){ + float dv = abs(value - uSlicePos * 10.0); + s = (1.0 - smoothstep(0.4, 0.75, dv)) * step(0.001, cfrac); + } + return s; +} + +void main(){ + float sIn = sliceGate(aAngle, aValue, aCFrac); + float lin = uMorph * uMorph * (3.0 - 2.0 * uMorph); + float s = min(1.0, aChroma / 13.0 * 1.12); + float cell; + vec2 g; + if (uSliceMode > 1.5){ + cell = uCellVal; + float hIdx = aAngle / 6.2831853 * uHueCount; + g = vec2(hIdx - uHueCount * 0.5 + 0.5, s * 8.0 - 4.5); + } else { + cell = uCellHue; + float phi = uSlicePos * 3.14159265; + float dA = atan(sin(aAngle - phi), cos(aAngle - phi)); + float side = step(abs(dA), 1.5707963) * 2.0 - 1.0; + g = vec2(side * s * 8.0, aValue - 5.0); + } + float ch = smoothstep(0.02, 1.0, uChart); + vA = ch * lin * sIn * (1.0 - uMini); + vec3 P = uAnchor + uCamR * (g.x * cell) + uCamU * (g.y * cell); + vec4 mv = viewMatrix * vec4(P, 1.0); + gl_Position = projectionMatrix * mv; + gl_PointSize = min(cell * 1.05 * uScale / max(-mv.z, 0.5), 220.0) * step(0.01, vA); +} +`; +export const RING_FSH = ` +precision highp float; +varying float vA; +void main(){ + if (vA < 0.02) discard; + vec2 p = gl_PointCoord * 2.0 - 1.0; + float d = length(p); + float core = 1.0 - smoothstep(0.13, 0.22, d); + float shad = (1.0 - smoothstep(0.16, 0.58, d)) * 0.45; + float a = max(core, shad) * vA; + if (a < 0.01) discard; + vec3 col = mix(vec3(0.02, 0.03, 0.05), vec3(0.97, 0.98, 1.0), core); + gl_FragColor = vec4(col, a); +} +`; diff --git a/5ee9e6-color-tree/utils/tree-engine.gts b/5ee9e6-color-tree/utils/tree-engine.gts new file mode 100644 index 00000000..a0e2f663 --- /dev/null +++ b/5ee9e6-color-tree/utils/tree-engine.gts @@ -0,0 +1,556 @@ +// ============================================================================= +// TreeEngine — owns the WebGL scene (renderer, cameras, the four render +// passes, camera framing, analytic picking). The isolated component owns +// the tracked UI state and drives this engine through targets/uniforms. +// ============================================================================= +// @ts-ignore — CDN module has no type defs +import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.128.0/+esm'; +import { + TAU, + V_STEP, + C_SCALE, + CENTER_V, + SPHERE_R, + HUE_TIERS, + maxChroma, + hslToRgb, + chipHex, + rgbHex, + lerpWrapDeg, + munsellNotation, + buildArrays, + type ChipArrays, +} from './munsell'; +import { + VOXEL_VSH, + VOXEL_FSH, + HALO_VSH, + HALO_FSH, + GRID_VSH, + GRID_FSH, + RING_VSH, + RING_FSH, +} from './shaders'; + +/* uniforms are shared by all four passes; they live here — with the + engine — because their camera-basis slots are THREE.Vector3s and this + module is the single owner of the CDN three import */ +export interface Uniforms { + [key: string]: { value: number | THREE.Vector3 }; +} + +export function makeUniforms(): Uniforms { + return { + uTime: { value: 0 }, + uMorph: { value: 0 }, + uChart: { value: 0 }, // 0 = in the solid, 1 = laid flat as the page + uChroma: { value: 1 }, + uContrast: { value: 0 }, + uAxis: { value: 0 }, + uGlow: { value: 0.5 }, + uSliceMode: { value: 0 }, + uSlicePos: { value: 0.5 }, + uSliceHalf: { value: 0.24 }, + uHueCount: { value: 20 }, + uCellHue: { value: 1 }, + uCellVal: { value: 1 }, + uScale: { value: 100 }, + uMini: { value: 0 }, // 1 while rendering the scout miniature + + uCamR: { value: new THREE.Vector3(1, 0, 0) }, + uCamU: { value: new THREE.Vector3(0, 1, 0) }, + uCamF: { value: new THREE.Vector3(0, 0, -1) }, + uAnchor: { value: new THREE.Vector3() }, + }; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + ENGINE — owns the WebGL scene; the component owns the tracked UI state + ═══════════════════════════════════════════════════════════════════════════ */ +export interface PickResult { + hex: string; + notation: string; +} + +/* the scout miniature's distance is solved once from the solid's full + extents — it always shows the whole specimen, never zoomed */ +const MINI_DIST = (16.6 * 1.18) / Math.tan((25 * Math.PI) / 180); + +export class TreeEngine { + renderer: THREE.WebGLRenderer; + scene = new THREE.Scene(); + camera = new THREE.PerspectiveCamera(55, 1, 0.2, 600); + miniCam = new THREE.PerspectiveCamera(50, 1, 0.2, 600); + miniFwd = new THREE.Vector3(); + miniSize = 150; + /* the scout can be waved away when it sits over the atlas page */ + showMini = true; + solid = new THREE.Group(); + uniforms = makeUniforms(); + arrays: ChipArrays; + mesh?: THREE.Mesh; + halo?: THREE.Points; + gridMat = new THREE.ShaderMaterial({ + uniforms: this.uniforms, + vertexShader: GRID_VSH, + fragmentShader: GRID_FSH, + transparent: true, + depthWrite: false, + depthTest: false, + }); + ringMat = new THREE.ShaderMaterial({ + uniforms: this.uniforms, + vertexShader: RING_VSH, + fragmentShader: RING_FSH, + transparent: true, + depthWrite: false, + depthTest: false, + }); + gridPts = new THREE.Points(new THREE.BufferGeometry(), this.gridMat); + ringPts = new THREE.Points(new THREE.BufferGeometry(), this.ringMat); + canvas: HTMLCanvasElement; + paused = false; + morphTarget = 0; + sliceTarget = 0; + chartTarget = 0; // 0 = atlas closed · 1 = atlas open (laid flat as the page) + raf = 0; + rotY = 0.6; + rotX = -0.25; + velY = 0.0015; + velX = 0; + baseSpin = 0.0015; + dist = 46; + userApproached = false; // a distance the user chose is respected + tanV = 1; // tan(fov/2) — the page hangs at a size solved from this each frame + densityIdx = 1; + disposed = false; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + powerPreference: 'high-performance', + }); + this.renderer.setClearColor(0x000000, 0); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75)); + this.scene.add(this.solid); + this.gridPts.frustumCulled = false; + this.ringPts.frustumCulled = false; + this.gridPts.renderOrder = 2; + this.ringPts.renderOrder = 3; + this.scene.add(this.gridPts, this.ringPts); + this.arrays = buildArrays(HUE_TIERS[this.densityIdx], this.densityIdx); + this.uniforms.uHueCount.value = HUE_TIERS[this.densityIdx]; + this.uniforms.uSliceHalf.value = Math.PI / HUE_TIERS[this.densityIdx]; + this.buildMesh(); + this.buildGuides(); + this.buildGrid(0, 0.5); + this.resize(); + // open on the fitted framing immediately, not eased from a guess + this.dist = this.fitDistance(); + this.loop(); + } + + buildMesh() { + if (this.mesh) { + this.solid.remove(this.mesh); + (this.mesh.geometry as THREE.BufferGeometry).dispose(); + (this.mesh.material as THREE.Material).dispose(); + } + let a = this.arrays; + let box = new THREE.BoxGeometry(1, 1, 1); + let g = new THREE.InstancedBufferGeometry(); + g.index = box.index; + g.setAttribute('position', box.attributes.position); + g.setAttribute('normal', box.attributes.normal); + let add = (name: string, arr: number[], size: number) => + g.setAttribute( + name, + new THREE.InstancedBufferAttribute(new Float32Array(arr), size), + ); + add('aTree', a.tree, 3); + add('aSphere', a.sphere, 3); + add('aColor', a.cols, 3); + add('aCFrac', a.cfr, 1); + add('aAngle', a.ang, 1); + add('aSeed', a.seed, 1); + add('aValue', a.val, 1); + add('aChroma', a.chr, 1); + add('aEdge', a.edg, 1); + let mat = new THREE.ShaderMaterial({ + uniforms: this.uniforms, + vertexShader: VOXEL_VSH, + fragmentShader: VOXEL_FSH, + }); + this.mesh = new THREE.Mesh(g, mat); + this.mesh.frustumCulled = false; + this.solid.add(this.mesh); + // the halo pass: one soft point of light per voxel, added not painted + if (this.halo) { + this.solid.remove(this.halo); + (this.halo.geometry as THREE.BufferGeometry).dispose(); + (this.halo.material as THREE.Material).dispose(); + } + let hg = new THREE.BufferGeometry(); + hg.setAttribute('position', new THREE.Float32BufferAttribute(a.tree, 3)); + hg.setAttribute('aSphere', new THREE.Float32BufferAttribute(a.sphere, 3)); + hg.setAttribute('aColor', new THREE.Float32BufferAttribute(a.cols, 3)); + hg.setAttribute('aCFrac', new THREE.Float32BufferAttribute(a.cfr, 1)); + hg.setAttribute('aAngle', new THREE.Float32BufferAttribute(a.ang, 1)); + hg.setAttribute('aValue', new THREE.Float32BufferAttribute(a.val, 1)); + hg.setAttribute('aChroma', new THREE.Float32BufferAttribute(a.chr, 1)); + hg.setAttribute('aEdge', new THREE.Float32BufferAttribute(a.edg, 1)); + let hmat = new THREE.ShaderMaterial({ + uniforms: this.uniforms, + vertexShader: HALO_VSH, + fragmentShader: HALO_FSH, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + this.halo = new THREE.Points(hg, hmat); + this.halo.frustumCulled = false; + this.solid.add(this.halo); + + // the rings share the halo's per-voxel geometry — same voxels, marked + // instead of glowed, at their position within the ideal linear grid. + // Its old geometry is the same object the halo cleanup above just + // disposed (both pointed at the previous build's hg), so nothing + // further to free here. + this.ringPts.geometry = hg; + } + + buildGuides() { + // the pedestal: faint rings beneath the tree, and the gray axis + let ringMat = new THREE.LineBasicMaterial({ + color: 0x16273a, + transparent: true, + opacity: 0.8, + }); + let baseY = -CENTER_V * V_STEP - 1.4; + for (let c of [4, 8, 12, 16]) { + let pts: THREE.Vector3[] = []; + for (let i = 0; i <= 96; i++) { + let t = (i / 96) * TAU; + pts.push( + new THREE.Vector3( + Math.cos(t) * c * C_SCALE, + baseY, + Math.sin(t) * c * C_SCALE, + ), + ); + } + this.solid.add( + new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), ringMat), + ); + } + let axisPts = [ + new THREE.Vector3(0, -CENTER_V * V_STEP - 1.2, 0), + new THREE.Vector3(0, (10 - CENTER_V) * V_STEP + 1.2, 0), + ]; + this.solid.add( + new THREE.Line( + new THREE.BufferGeometry().setFromPoints(axisPts), + new THREE.LineBasicMaterial({ + color: 0x5c7484, + transparent: true, + opacity: 0.5, + }), + ), + ); + } + + setDensity(idx: number) { + this.densityIdx = idx; + this.arrays = buildArrays(HUE_TIERS[idx], idx); + this.uniforms.uHueCount.value = HUE_TIERS[idx]; + this.uniforms.uSliceHalf.value = Math.PI / HUE_TIERS[idx]; + this.buildMesh(); + this.buildGrid(this.gridMode, this.gridScrub); + } + + gridMode = 0; + gridScrub = 0.5; + + /* the synthetic linear-scale background: a complete conventional grid + (every saturation × lightness cell filled, whether or not a real + Munsell chip exists there) for the current cut, rebuilt whenever the + cut itself changes — hue leaf (mode 1) or value plane (mode 2). + Takes the TARGET mode/scrub directly rather than reading + uSliceMode/uSlicePos off the uniforms, since those ease toward their + target over several frames — reading them synchronously right after + a mode change would rebuild against a stale, still-mid-transition + value and leave the wrong background frozen in place once the ease + finishes (e.g. closing the atlas from VALUE mode would never clear + the value-mode grid, since nothing calls buildGrid() again after the + ease settles at mode 0). */ + buildGrid(mode: number, slicePos01: number) { + this.gridMode = mode; + this.gridScrub = slicePos01; + let pos: number[] = []; + let col: number[] = []; + if (mode === 1) { + let phi = slicePos01 * Math.PI; + let hA = phi / TAU; + let hB = (hA + 0.5) % 1; + for (let v = 0; v <= 10; v++) { + let l = 0.07 + (0.86 * v) / 10; + let gr = hslToRgb(0, 0, l); + pos.push(0, v - 5, 0); + col.push(gr[0], gr[1], gr[2]); + for (let s = 1; s <= 8; s++) { + let ra = hslToRgb(lerpWrapDeg(hA), s / 8, l); + pos.push(s, v - 5, 0); + col.push(ra[0], ra[1], ra[2]); + let rb = hslToRgb(lerpWrapDeg(hB), s / 8, l); + pos.push(-s, v - 5, 0); + col.push(rb[0], rb[1], rb[2]); + } + } + } else if (mode === 2) { + let H = HUE_TIERS[this.densityIdx]; + let v = Math.round(slicePos01 * 10); + let l = 0.07 + (0.86 * v) / 10; + for (let hi = 0; hi < H; hi++) { + let deg = lerpWrapDeg(hi / H); + for (let s = 1; s <= 8; s++) { + let rgb = hslToRgb(deg, s / 8, l); + pos.push(hi - H * 0.5 + 0.5, s - 4.5, 0); + col.push(rgb[0], rgb[1], rgb[2]); + } + } + } + let g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('aCol', new THREE.Float32BufferAttribute(col, 3)); + let old = this.gridPts.geometry; + this.gridPts.geometry = g; + old.dispose(); + } + + resize() { + let w = this.canvas.clientWidth || 1; + let h = this.canvas.clientHeight || 1; + this.renderer.setSize(w, h, false); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.tanV = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)); + // point-size scale for the GRID/RING passes, solved from the same + // fov math the rest of the page's framing uses + this.uniforms.uScale.value = + (h * this.renderer.getPixelRatio() * 0.5) / this.tanV; + this.miniSize = Math.round(Math.min(w * 0.32, 168)); + } + + /* SOLVE the framing from fov + aspect + the solid's bounding radius, + blended across the morph — the specimen fills the room by default + instead of sitting at a fixed distance. Because it can tumble to any + orientation, the bound is a sphere, never a box. */ + fitDistance(): number { + let reach = this.uniforms.uChroma.value as number; + let boundTree = Math.max(12.5, 2.5 + 13.6 * reach); + let m = this.uniforms.uMorph.value as number; + let bound = boundTree * (1 - m) + (SPHERE_R + 1.6) * m; + let dV = (bound * 1.28) / this.tanV; + let dH = dV / this.camera.aspect; + return Math.min(Math.max(Math.max(dV, dH), 16), 130); + } + + tumble(dx: number, dy: number) { + this.velY = dx * 0.005; + this.velX = dy * 0.005; + this.rotY += this.velY; + this.rotX = Math.max(-1.2, Math.min(1.2, this.rotX + this.velX)); + } + + zoom(delta: number) { + this.userApproached = true; + // the far clamp stays close enough that an accidental trackpad + // scroll can never shrink the specimen to a speck + this.dist = Math.max(18, Math.min(70, this.dist + delta)); + } + + /* double-click hands the framing back to the auto-fit */ + refit() { + this.userApproached = false; + } + + /* the page is analytic, so a click on it can be inverted exactly: screen + → chart plane → grid cell → Munsell coordinates → the same hex the + chip was painted with. Only valid while the atlas is open (sliceMode + off never invert-picks, since there's no page to click). */ + pickHex(cx: number, cy: number): PickResult | null { + let mode = this.uniforms.uSliceMode.value as number; + if (mode < 0.5) { + return null; + } + let rect = this.canvas.getBoundingClientRect(); + let ndcX = ((cx - rect.left) / rect.width) * 2 - 1; + let ndcY = 1 - ((cy - rect.top) / rect.height) * 2; + let gx = ndcX * this.tanV * this.camera.aspect * this.dist; + let gy = ndcY * this.tanV * this.dist; + let ideal = Math.round(this.uniforms.uMorph.value as number) === 1; + let H = HUE_TIERS[this.densityIdx]; + if (mode > 1.5) { + let cell = this.uniforms.uCellVal.value as number; + let v = Math.round((this.uniforms.uSlicePos.value as number) * 10); + let hIdx = Math.round(gx / cell + H * 0.5 - 0.5); + if (hIdx < 0 || hIdx >= H) { + return null; + } + let hHue = hIdx / H; + let row = Math.round(gy / cell + 4.5); + if (row < 1 || row > 8) { + return null; + } + if (ideal) { + let [r, g, b] = hslToRgb( + lerpWrapDeg(hHue), + row / 8, + 0.07 + (0.86 * v) / 10, + ); + return { + hex: rgbHex(r, g, b), + notation: munsellNotation(hHue, v, (row / 8) * 13), + }; + } + let c = 2 * row; + if (c > maxChroma(hHue, v) + 0.01) { + return null; + } + return { + hex: chipHex(hHue, v, c), + notation: munsellNotation(hHue, v, c), + }; + } + let cell = this.uniforms.uCellHue.value as number; + let v = Math.round(gy / cell + 5); + if (v < 0 || v > 10) { + return null; + } + let u = gx / cell; + let phi = (this.uniforms.uSlicePos.value as number) * Math.PI; + let hHue = ((((u >= 0 ? phi : phi + Math.PI) / TAU) % 1) + 1) % 1; + let col = Math.round(Math.abs(u)); + if (col > 8) { + return null; + } + if (ideal) { + if (col === 0) { + let [r, g, b] = hslToRgb(0, 0, 0.07 + (0.86 * v) / 10); + return { hex: rgbHex(r, g, b), notation: munsellNotation(hHue, v, 0) }; + } + let [r, g, b] = hslToRgb( + lerpWrapDeg(hHue), + col / 8, + 0.07 + (0.86 * v) / 10, + ); + return { + hex: rgbHex(r, g, b), + notation: munsellNotation(hHue, v, (col / 8) * 13), + }; + } + if (col === 0) { + return { + hex: chipHex(hHue, v, 0), + notation: munsellNotation(hHue, v, 0), + }; + } + let c = 2 * col; + if (c > maxChroma(hHue, v) + 0.01) { + return null; + } + return { hex: chipHex(hHue, v, c), notation: munsellNotation(hHue, v, c) }; + } + + loop = () => { + if (this.disposed) { + return; + } + this.raf = requestAnimationFrame(this.loop); + let u = this.uniforms; + if (!this.paused) { + u.uTime.value += 1 / 60; + this.rotY += this.velY; + this.velY *= 0.96; + this.velX *= 0.9; + if (Math.abs(this.velY) < this.baseSpin + 0.0001) { + this.velY = this.baseSpin; // the specimen keeps slowly turning at rest + } + } + // spring the morph, slice, and chart (atlas open/close) toward their targets + u.uMorph.value += (this.morphTarget - (u.uMorph.value as number)) * 0.06; + u.uSliceMode.value += + (this.sliceTarget - (u.uSliceMode.value as number)) * 0.15; + u.uChart.value += (this.chartTarget - (u.uChart.value as number)) * 0.08; + u.uAxis.value = -this.rotY + Math.PI * 0.25; + this.solid.rotation.set(this.rotX, this.rotY, 0); + if (!this.userApproached) { + this.dist += (this.fitDistance() - this.dist) * 0.05; + } + this.camera.position.set(0, 4, this.dist); + this.camera.lookAt(0, 0, 0); + + // the page hangs square to the eye, at the solid's depth — its basis + // is the camera's own right/up/forward, not the tumbling solid's + let camR = (u.uCamR.value as THREE.Vector3).set(1, 0, 0); + camR.applyQuaternion(this.camera.quaternion); + let camU = (u.uCamU.value as THREE.Vector3).set(0, 1, 0); + camU.applyQuaternion(this.camera.quaternion); + let camF = (u.uCamF.value as THREE.Vector3).set(0, 0, -1); + camF.applyQuaternion(this.camera.quaternion); + (u.uAnchor.value as THREE.Vector3) + .copy(this.camera.position) + .addScaledVector(camF, this.dist); + + let hW = 2 * this.dist * this.tanV; + let wW = hW * this.camera.aspect; + u.uCellHue.value = Math.min((hW * 0.7) / 11, (wW * 0.84) / 18); + let H = HUE_TIERS[this.densityIdx]; + u.uCellVal.value = Math.min((hW * 0.7) / 9.5, (wW * 0.84) / (H + 4)); + + let cw = this.canvas.clientWidth || 1; + let chh = this.canvas.clientHeight || 1; + this.renderer.setViewport(0, 0, cw, chh); + this.renderer.render(this.scene, this.camera); + + /* the scout: a second small pass from the same bearing, showing the + whole solid with the current cut lit bright inside it */ + if (this.showMini && (u.uChart.value as number) > 0.02) { + u.uMini.value = 1; + this.miniFwd.copy(this.camera.position).normalize(); + this.miniCam.position.copy(this.miniFwd).multiplyScalar(MINI_DIST); + this.miniCam.lookAt(0, 0, 0); + this.renderer.setScissorTest(true); + this.renderer.setScissor(14, 110, this.miniSize, this.miniSize); + this.renderer.setViewport(14, 110, this.miniSize, this.miniSize); + this.renderer.render(this.scene, this.miniCam); + this.renderer.setScissorTest(false); + u.uMini.value = 0; + } + }; + + dispose() { + this.disposed = true; + cancelAnimationFrame(this.raf); + this.gridPts.geometry.dispose(); + this.gridMat.dispose(); + this.ringMat.dispose(); + // free every geometry/material still in the scene graph (voxel mesh, + // halo points, pedestal guide lines) rather than leaning on GC + this.solid.traverse((obj: THREE.Object3D) => { + let o = obj as THREE.Mesh; + o.geometry?.dispose?.(); + if (Array.isArray(o.material)) { + for (let m of o.material) { + m.dispose(); + } + } else { + o.material?.dispose?.(); + } + }); + this.renderer.dispose(); + } +}