@developerz.ai/ts23d 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +1 -1
  3. package/README.md +10 -5
  4. package/dist/{chunk-a8fxakcj.js → chunk-6b05mnmx.js} +2 -2
  5. package/dist/{chunk-kgvbpqqw.js → chunk-7rtfjh9f.js} +6 -5
  6. package/dist/chunk-7rtfjh9f.js.map +10 -0
  7. package/dist/{chunk-1ypfs7ga.js → chunk-8j4c28jt.js} +790 -276
  8. package/dist/chunk-8j4c28jt.js.map +46 -0
  9. package/dist/{chunk-wtqhq529.js → chunk-d1fv6hrm.js} +6 -6
  10. package/dist/{chunk-wtqhq529.js.map → chunk-d1fv6hrm.js.map} +1 -1
  11. package/dist/{chunk-gcyp9b4d.js → chunk-dbq4qf52.js} +362 -215
  12. package/dist/chunk-dbq4qf52.js.map +25 -0
  13. package/dist/{chunk-xnt2sdhf.js → chunk-drna2rny.js} +2 -2
  14. package/dist/{chunk-ravbb06q.js → chunk-dxvypr7y.js} +2 -2
  15. package/dist/{chunk-11v28977.js → chunk-f6yme003.js} +85 -71
  16. package/dist/{chunk-11v28977.js.map → chunk-f6yme003.js.map} +6 -6
  17. package/dist/{chunk-4ddzsqtc.js → chunk-hg7pf34j.js} +4 -3
  18. package/dist/{chunk-4ddzsqtc.js.map → chunk-hg7pf34j.js.map} +5 -5
  19. package/dist/{chunk-w1zyhj40.js → chunk-jq4e17tj.js} +2 -2
  20. package/dist/{chunk-kvcep7ft.js → chunk-y63c6zcv.js} +967 -218
  21. package/dist/chunk-y63c6zcv.js.map +53 -0
  22. package/dist/cli/args.d.ts +7 -1
  23. package/dist/cli/bin.js +66 -25
  24. package/dist/cli/bin.js.map +6 -5
  25. package/dist/cli/manifest.d.ts +5 -1
  26. package/dist/cli/texture-cache.d.ts +4 -0
  27. package/dist/core/definition.d.ts +7 -0
  28. package/dist/core/errors.d.ts +1 -1
  29. package/dist/core/index.js +1 -1
  30. package/dist/export/credits.d.ts +4 -0
  31. package/dist/export/glb-options.d.ts +48 -0
  32. package/dist/export/glb.d.ts +5 -35
  33. package/dist/export/hierarchy.d.ts +23 -0
  34. package/dist/export/index.d.ts +2 -1
  35. package/dist/export/index.js +21 -7
  36. package/dist/export/index.js.map +1 -1
  37. package/dist/export/lod-coverage.d.ts +12 -0
  38. package/dist/export/metadata.d.ts +2 -0
  39. package/dist/export/version.d.ts +2 -2
  40. package/dist/grammar/index.js +2 -2
  41. package/dist/index.js +63 -17
  42. package/dist/index.js.map +1 -1
  43. package/dist/kinds/head-armour.d.ts +3 -7
  44. package/dist/kinds/head-bust.d.ts +3 -2
  45. package/dist/kinds/head-face.d.ts +3 -1
  46. package/dist/kinds/head-garb.d.ts +4 -0
  47. package/dist/kinds/head-mesh.d.ts +4 -0
  48. package/dist/kinds/head-mouth.d.ts +4 -0
  49. package/dist/kinds/head-params.d.ts +9 -1
  50. package/dist/kinds/head-tone.d.ts +10 -0
  51. package/dist/kinds/head-torso.d.ts +35 -0
  52. package/dist/kinds/index.js +6 -6
  53. package/dist/material/cc0.d.ts +4 -0
  54. package/dist/material/define.d.ts +34 -0
  55. package/dist/material/generator.d.ts +1 -1
  56. package/dist/material/index.d.ts +8 -3
  57. package/dist/material/index.js +39 -7
  58. package/dist/material/index.js.map +1 -1
  59. package/dist/material/library.d.ts +7 -0
  60. package/dist/material/material-def.d.ts +30 -0
  61. package/dist/material/paint/canvas.d.ts +7 -0
  62. package/dist/material/paint/default-sheet.d.ts +3 -1
  63. package/dist/material/paint/fabric.d.ts +4 -0
  64. package/dist/material/paint/image-strip.d.ts +4 -0
  65. package/dist/material/paint/kit.d.ts +5 -0
  66. package/dist/material/paint/metal.d.ts +3 -0
  67. package/dist/material/paint/orm.d.ts +1 -1
  68. package/dist/material/paint/strip.d.ts +1 -1
  69. package/dist/material/paint/types.d.ts +32 -2
  70. package/dist/material/palette.d.ts +2 -0
  71. package/dist/material/slots.d.ts +5 -0
  72. package/dist/material/texture-source.d.ts +34 -0
  73. package/dist/material/trim-sheet.d.ts +20 -4
  74. package/dist/neural/index.js +2 -2
  75. package/dist/passes/index.js +2 -2
  76. package/dist/primitives/index.js +3 -3
  77. package/dist/qa/index.js +2 -2
  78. package/dist/render/beauty.d.ts +1 -1
  79. package/dist/render/index.js +3 -3
  80. package/dist/render/lighting.d.ts +7 -0
  81. package/dist/render/shade.d.ts +1 -1
  82. package/dist/testing/index.js +5 -5
  83. package/docs/README.md +4 -0
  84. package/docs/architecture/gltf-capabilities.md +38 -1
  85. package/docs/architecture/materials.md +151 -0
  86. package/docs/idea/10-sculpting.md +166 -0
  87. package/docs/idea/11-materials-authoring.md +132 -0
  88. package/docs/idea/12-blender-bridge.md +136 -0
  89. package/docs/idea/13-asset-pipeline-research.md +89 -0
  90. package/docs/plans/roadmap.md +12 -0
  91. package/package.json +2 -1
  92. package/recipes/boxcar/README.md +7 -0
  93. package/recipes/boxcar/boxcar.ts +118 -34
  94. package/recipes/goblin_head/README.md +27 -14
  95. package/recipes/goblin_head/goblin_head.ts +11 -9
  96. package/recipes/human_head/README.md +8 -4
  97. package/recipes/human_head/human_head.ts +4 -3
  98. package/recipes/material_swatches/material_swatches.ts +11 -5
  99. package/recipes/orc_head/README.md +32 -20
  100. package/recipes/orc_head/orc_head.ts +9 -7
  101. package/src/cli/args.ts +16 -3
  102. package/src/cli/manifest.ts +6 -0
  103. package/src/cli/run.ts +33 -9
  104. package/src/cli/texture-cache.ts +24 -0
  105. package/src/core/asset.ts +1 -0
  106. package/src/core/definition.ts +7 -0
  107. package/src/core/errors.ts +6 -1
  108. package/src/export/credits.ts +25 -0
  109. package/src/export/glb-options.ts +53 -0
  110. package/src/export/glb.ts +100 -113
  111. package/src/export/hierarchy.ts +122 -0
  112. package/src/export/index.ts +17 -1
  113. package/src/export/lod-coverage.ts +71 -0
  114. package/src/export/metadata.ts +3 -0
  115. package/src/export/version.ts +1 -1
  116. package/src/kinds/head-armour.ts +103 -73
  117. package/src/kinds/head-bust.ts +49 -86
  118. package/src/kinds/head-face.ts +94 -74
  119. package/src/kinds/head-garb.ts +181 -0
  120. package/src/kinds/head-hair.ts +74 -33
  121. package/src/kinds/head-mesh.ts +43 -5
  122. package/src/kinds/head-mouth.ts +111 -0
  123. package/src/kinds/head-params.ts +11 -2
  124. package/src/kinds/head-presets.ts +30 -27
  125. package/src/kinds/head-tone.ts +52 -0
  126. package/src/kinds/head-torso.ts +230 -0
  127. package/src/kinds/head.ts +8 -3
  128. package/src/material/cc0.ts +41 -0
  129. package/src/material/define.ts +130 -0
  130. package/src/material/generator.ts +15 -8
  131. package/src/material/index.ts +34 -3
  132. package/src/material/library.ts +196 -0
  133. package/src/material/material-def.ts +84 -0
  134. package/src/material/paint/canvas.ts +11 -1
  135. package/src/material/paint/compose.ts +1 -1
  136. package/src/material/paint/decode-png.ts +19 -13
  137. package/src/material/paint/default-sheet.ts +40 -28
  138. package/src/material/paint/fabric.ts +63 -0
  139. package/src/material/paint/image-strip.ts +88 -0
  140. package/src/material/paint/kit.ts +37 -0
  141. package/src/material/paint/masks.ts +3 -1
  142. package/src/material/paint/metal.ts +109 -0
  143. package/src/material/paint/orm.ts +14 -1
  144. package/src/material/paint/planks.ts +49 -4
  145. package/src/material/paint/strip.ts +35 -29
  146. package/src/material/paint/types.ts +45 -2
  147. package/src/material/paint/validate.ts +19 -3
  148. package/src/material/palette.ts +3 -0
  149. package/src/material/slots.ts +18 -0
  150. package/src/material/texture-source.ts +86 -0
  151. package/src/material/trim-sheet.ts +79 -13
  152. package/src/render/beauty.ts +2 -2
  153. package/src/render/lighting.ts +26 -0
  154. package/src/render/shade.ts +10 -5
  155. package/dist/chunk-1ypfs7ga.js.map +0 -42
  156. package/dist/chunk-gcyp9b4d.js.map +0 -22
  157. package/dist/chunk-kgvbpqqw.js.map +0 -10
  158. package/dist/chunk-kvcep7ft.js.map +0 -43
  159. /package/dist/{chunk-a8fxakcj.js.map → chunk-6b05mnmx.js.map} +0 -0
  160. /package/dist/{chunk-xnt2sdhf.js.map → chunk-drna2rny.js.map} +0 -0
  161. /package/dist/{chunk-ravbb06q.js.map → chunk-dxvypr7y.js.map} +0 -0
  162. /package/dist/{chunk-w1zyhj40.js.map → chunk-jq4e17tj.js.map} +0 -0
@@ -0,0 +1,111 @@
1
+ // Lips and mouth as edit lists, part of the skull field: upper and lower lip pads (the lower one
2
+ // pushed forward by the underbite), then the opening — a thin scowl line when `grin` is low, or a
3
+ // crescent cut (a wide smile, corners lifted) lined with small pointed teeth along the dental arch
4
+ // when it is high. Head units, mirrored across Y.
5
+
6
+ import type { SculptEdit } from '../primitives/index.ts';
7
+ import { alongZ } from './head-geom.ts';
8
+ import type { HeadLayout } from './head-layout.ts';
9
+ import type { HeadParams } from './head-params.ts';
10
+
11
+ export function mouthEdits(p: HeadParams, l: HeadLayout, teeth = true): SculptEdit[] {
12
+ const c = p.colors;
13
+ const lipT = p.mouth.lips;
14
+ const mw = p.mouth.width;
15
+ const g = p.mouth.grin;
16
+ const out: SculptEdit[] = [
17
+ {
18
+ shape: 'ellipsoid',
19
+ at: [l.mouthX - 0.01, 0, l.mouthZ + lipT * 0.7],
20
+ size: [0.032, mw, lipT],
21
+ color: c.lips,
22
+ blend: 0.03,
23
+ },
24
+ {
25
+ shape: 'ellipsoid',
26
+ at: [l.mouthX - 0.015 + p.jaw.underbite * 0.7, 0, l.mouthZ - lipT * 0.8],
27
+ size: [0.034 + p.jaw.underbite * 0.3, mw * 0.85, lipT * (1.1 + p.jaw.underbite * 4)],
28
+ color: c.lips,
29
+ blend: 0.03,
30
+ },
31
+ ];
32
+ // Far LODs carve a grin as a plain wide line: a crescent that thin folds over at coarse cells.
33
+ if (g < 0.4 || !teeth) {
34
+ out.push({
35
+ shape: 'capsule',
36
+ at: [l.mouthX + 0.05, 0, l.mouthZ],
37
+ to: [l.mouthX - 0.03 - 0.02 * g, mw * 0.98, l.mouthZ + 0.012 + g * 0.05 - p.brow.scowl * 0.4],
38
+ size: 0.006 + g * 0.022,
39
+ op: 'subtract',
40
+ blend: 0.012,
41
+ color: c.mouth,
42
+ });
43
+ return out;
44
+ }
45
+ // A grin: the part of a wide ellipsoid below a higher one — a crescent whose horns rise.
46
+ const h = 0.035 + g * 0.05;
47
+ const z0 = l.mouthZ + h * 0.45;
48
+ const back = mw * 0.55;
49
+ out.push({
50
+ group: [
51
+ { shape: 'ellipsoid', at: [l.mouthX - back, 0, z0], size: [back + 0.06, mw, h] },
52
+ {
53
+ shape: 'ellipsoid',
54
+ at: [l.mouthX - back + 0.02, 0, z0 + h * 0.7],
55
+ size: [back + 0.12, mw * 1.1, h * 0.9],
56
+ op: 'subtract',
57
+ },
58
+ ],
59
+ op: 'subtract',
60
+ blend: 0.012,
61
+ color: c.mouth,
62
+ });
63
+ // A tongue fills the floor of the open mouth (so no flat jaw cut shows through the grin).
64
+ if (teeth)
65
+ out.push({
66
+ shape: 'ellipsoid',
67
+ at: [l.mouthX - back * 0.9, 0, l.mouthZ - h * 0.4],
68
+ size: [back * 0.9, mw * 0.62, h * 0.45],
69
+ color: [0.42, 0.12, 0.12],
70
+ blend: 0.01,
71
+ });
72
+ // The grin bunches the cheeks up and out beside the mouth corners.
73
+ out.push({
74
+ shape: 'ellipsoid',
75
+ at: [l.mouthX - 0.06, mw * 0.95, z0 + h * 0.6],
76
+ size: [0.05, 0.045, 0.04 + g * 0.01],
77
+ rotate: [-30, 0, 0],
78
+ blend: 0.035,
79
+ });
80
+ // Teeth: pointed little cones along the arch — the upper row hangs, the lower row rises.
81
+ const n = teeth ? 3 : 0;
82
+ for (let i = 0; i < n; i++) {
83
+ const a = (0.25 + i) * 0.36;
84
+ const y = Math.sin(a) * mw * 0.82;
85
+ const x = l.mouthX - 0.03 - (1 - Math.cos(a)) * back * 1.3;
86
+ // Follow the crescent: roots just inside its upper and lower edges, corners higher.
87
+ const lo = z0 - h * Math.sqrt(Math.max(0, 1 - (y / mw) ** 2));
88
+ const hi = z0 + h * 0.7 - h * 0.9 * Math.sqrt(Math.max(0, 1 - (y / (mw * 1.1)) ** 2));
89
+ const len = 0.03 + g * 0.008 - i * 0.004;
90
+ const r = 0.014 + g * 0.004 - i * 0.0015;
91
+ out.push(
92
+ {
93
+ shape: 'cone',
94
+ at: [x, y, hi + 0.008],
95
+ size: [r, 0.003, len],
96
+ rotate: alongZ([0.15, 0, -1]),
97
+ color: c.teeth,
98
+ blend: 0.004,
99
+ },
100
+ {
101
+ shape: 'cone',
102
+ at: [x - 0.006, y * 0.97, lo - 0.008],
103
+ size: [r * 0.85, 0.003, len * 0.6],
104
+ rotate: alongZ([0.1, 0, 1]),
105
+ color: c.teeth,
106
+ blend: 0.004,
107
+ },
108
+ );
109
+ }
110
+ return out;
111
+ }
@@ -7,7 +7,7 @@ import { Ts23dError, type Vec3 } from '../core/index.ts';
7
7
  export type EarShape = 'round' | 'pointed';
8
8
  export type HairStyle = 'none' | 'topknot' | 'mohawk' | 'swept' | 'tufts' | 'braids';
9
9
  export type BeardStyle = 'none' | 'short' | 'braided' | 'goatee';
10
- export type BustStyle = 'pauldrons' | 'hood' | 'tunic';
10
+ export type BustStyle = 'pauldrons' | 'hood' | 'tunic' | 'vest';
11
11
 
12
12
  export interface HeadColors {
13
13
  readonly skin: Vec3;
@@ -29,6 +29,10 @@ export interface HeadColors {
29
29
  readonly fur?: Vec3;
30
30
  /** War paint / tattoo stripes (orc), or none. */
31
31
  readonly paint?: Vec3;
32
+ /** The bust's plinth (stone/wood socle); default a dark slate. */
33
+ readonly plinth?: Vec3;
34
+ /** Scarf / sash cloth (vest style); default `cloth`. */
35
+ readonly scarf?: Vec3;
32
36
  }
33
37
 
34
38
  export interface HeadParams {
@@ -88,11 +92,14 @@ export interface HeadParams {
88
92
  readonly flare: number;
89
93
  } | null;
90
94
  readonly neck: { readonly radius: number };
91
- readonly bust: { readonly shoulders: number; readonly style: BustStyle };
95
+ /** Shoulder half-span, gear style, muscle mass (0 = bony and narrow … 1 = brute; default 0.5). */
96
+ readonly bust: { readonly shoulders: number; readonly style: BustStyle; readonly mass?: number };
92
97
  readonly hair: HairStyle;
93
98
  readonly beard: BeardStyle;
94
99
  /** Wart / bump count scattered on the skin (seeded). */
95
100
  readonly warts: number;
101
+ /** Forehead creases (0 = smooth; a goblin's worried brow has 3). */
102
+ readonly wrinkles?: number;
96
103
  readonly colors: HeadColors;
97
104
  }
98
105
 
@@ -122,6 +129,8 @@ const ZERO_OK = new Set([
122
129
  'jaw.muzzle',
123
130
  'brow.overhang',
124
131
  'brow.hair',
132
+ 'bust.mass',
133
+ 'wrinkles',
125
134
  ]);
126
135
 
127
136
  /** Throws T3D_RECIPE_INVALID (with a fix) when `p` cannot make a head. */
@@ -43,33 +43,33 @@ export const human: HeadParams = {
43
43
  /** Massive underbitten jaw, tusks, heavy brow over small deep eyes, flat broad nose, thick neck. */
44
44
  export const orc: HeadParams = {
45
45
  height: 0.3,
46
- cranium: { width: 0.7, depth: 0.8, height: 0.34 },
47
- brow: { ridge: 0.065, overhang: 0.075, scowl: 0.035 },
48
- cheeks: { size: 1.35, width: 1.15 },
49
- jaw: { muzzle: 0.1, width: 0.37, chin: 1.6, underbite: 0.07, drop: 0.1 },
50
- nose: { length: 0.15, width: 0.09, tip: 0.04, hook: -0.01, bridge: -0.045 },
51
- ears: { shape: 'pointed', length: 0.3, width: 0.17, sweep: 25, lift: 8 },
52
- eyes: { radius: 0.05, spacing: 0.155, line: 0.56, lid: 16, lowerLid: 26 },
53
- mouth: { width: 0.17, lips: 0.03, grin: 0 },
54
- tusks: { length: 0.27, radius: 0.05, spread: 0.14, flare: 0.35 },
55
- neck: { radius: 0.32 },
56
- bust: { shoulders: 1.05, style: 'pauldrons' },
46
+ cranium: { width: 0.7, depth: 0.74, height: 0.31 },
47
+ brow: { ridge: 0.075, overhang: 0.06, scowl: 0.04 },
48
+ cheeks: { size: 1.3, width: 1.15 },
49
+ jaw: { muzzle: 0.1, width: 0.4, chin: 1.3, underbite: 0.1, drop: 0.1 },
50
+ nose: { length: 0.15, width: 0.1, tip: 0.042, hook: -0.01, bridge: -0.05 },
51
+ ears: { shape: 'pointed', length: 0.38, width: 0.19, sweep: 30, lift: 10 },
52
+ eyes: { radius: 0.054, spacing: 0.155, line: 0.56, lid: 10, lowerLid: 22 },
53
+ mouth: { width: 0.17, lips: 0.034, grin: 0 },
54
+ tusks: { length: 0.22, radius: 0.055, spread: 0.15, flare: 0.35 },
55
+ neck: { radius: 0.38 },
56
+ bust: { shoulders: 1.0, style: 'pauldrons', mass: 1 },
57
57
  hair: 'topknot',
58
58
  beard: 'none',
59
59
  warts: 6,
60
60
  colors: {
61
- skin: hex('#5f8b3a'),
62
- shade: hex('#3c5c24'),
61
+ skin: hex('#5c8034'),
62
+ shade: hex('#33501e'),
63
63
  lips: hex('#4f6f30'),
64
64
  sclera: hex('#8a5a1a'),
65
65
  iris: hex('#ffb030'),
66
66
  pupil: hex('#140a06'),
67
67
  teeth: hex('#e2d6b0'),
68
68
  mouth: hex('#2a0e0c'),
69
- hair: hex('#1c1714'),
69
+ hair: hex('#231a14'),
70
70
  cloth: hex('#6b2a20'),
71
71
  leather: hex('#5a3a22'),
72
- metal: hex('#6e6a62'),
72
+ metal: hex('#55524d'),
73
73
  fur: hex('#5e4a34'),
74
74
  paint: hex('#8a1c12'),
75
75
  },
@@ -79,19 +79,20 @@ export const orc: HeadParams = {
79
79
  export const goblin: HeadParams = {
80
80
  height: 0.19,
81
81
  cranium: { width: 0.66, depth: 0.84, height: 0.5 },
82
- brow: { ridge: 0.03, overhang: 0.015, scowl: 0.025 },
83
- cheeks: { size: 1.15, width: 1.1 },
84
- jaw: { muzzle: 0.06, width: 0.2, chin: 0.85, underbite: 0, drop: 0 },
85
- nose: { length: 0.2, width: 0.07, tip: 0.05, hook: 0.07, bridge: 0.12 },
86
- ears: { shape: 'pointed', length: 0.62, width: 0.3, sweep: 22, lift: 18 },
87
- eyes: { radius: 0.072, spacing: 0.15, line: 0.5, lid: 16, lowerLid: 26 },
88
- mouth: { width: 0.2, lips: 0.016, grin: 0.75 },
82
+ brow: { ridge: 0.032, overhang: 0.02, scowl: 0.03 },
83
+ cheeks: { size: 1.05, width: 1.05 },
84
+ jaw: { muzzle: 0.05, width: 0.19, chin: 0.72, underbite: 0, drop: 0 },
85
+ nose: { length: 0.2, width: 0.06, tip: 0.046, hook: 0.065, bridge: 0.13 },
86
+ ears: { shape: 'pointed', length: 0.66, width: 0.3, sweep: 24, lift: 6 },
87
+ eyes: { radius: 0.08, spacing: 0.155, line: 0.5, lid: 30, lowerLid: 20 },
88
+ mouth: { width: 0.21, lips: 0.014, grin: 0.8 },
89
89
  tusks: null,
90
- neck: { radius: 0.2 },
91
- bust: { shoulders: 0.75, style: 'hood' },
90
+ neck: { radius: 0.14 },
91
+ bust: { shoulders: 0.52, style: 'vest', mass: 0 },
92
92
  hair: 'tufts',
93
93
  beard: 'none',
94
94
  warts: 4,
95
+ wrinkles: 3,
95
96
  colors: {
96
97
  skin: hex('#9cb84a'),
97
98
  shade: hex('#6a8a2c'),
@@ -101,10 +102,12 @@ export const goblin: HeadParams = {
101
102
  pupil: hex('#120c04'),
102
103
  teeth: hex('#e8dcae'),
103
104
  mouth: hex('#3a1010'),
104
- hair: hex('#3a3024'),
105
- cloth: hex('#6a4a7a'),
106
- leather: hex('#5a3a22'),
105
+ hair: hex('#c8c0b0'),
106
+ cloth: hex('#b8a47c'),
107
+ cape: hex('#4e5e3a'),
108
+ leather: hex('#6b4428'),
107
109
  metal: hex('#a88a4a'),
110
+ scarf: hex('#9a3a24'),
108
111
  },
109
112
  };
110
113
 
@@ -0,0 +1,52 @@
1
+ // Hand-painted tone for sculpted head/bust vertex colour: the painter's three passes done from the
2
+ // distance field itself — cavity (crevices, folds, sockets go darker and a touch more saturated),
3
+ // a warm key from above (top planes lifted and warmed), and a cool bounce underneath. Pure field
4
+ // sampling at the surface point, so it is deterministic and costs ~9 field evaluations per vertex.
5
+
6
+ import type { Vec3 } from '../core/index.ts';
7
+ import type { Field } from '../primitives/index.ts';
8
+
9
+ export interface ToneOptions {
10
+ /** Probe distance for the cavity test, head units (≈ the size of a fold). */
11
+ readonly reach: number;
12
+ /** 0 = flat colour, 1 = full painted tone. */
13
+ readonly strength?: number;
14
+ }
15
+
16
+ /** Wraps `colorAt` with cavity darkening and a warm-top / cool-under gradient. */
17
+ export function toned(field: Field, colorAt: (q: Vec3) => Vec3, o: ToneOptions): (q: Vec3) => Vec3 {
18
+ const k = o.strength ?? 1;
19
+ const d = o.reach;
20
+ const e = d * 0.25;
21
+ return (q) => {
22
+ const c = colorAt(q);
23
+ const [x, y, z] = q;
24
+ const gx = field([x + e, y, z]) - field([x - e, y, z]);
25
+ const gy = field([x, y + e, z]) - field([x, y - e, z]);
26
+ const gz = field([x, y, z + e]) - field([x, y, z - e]);
27
+ const gl = Math.hypot(gx, gy, gz) || 1;
28
+ const n: Vec3 = [gx / gl, gy / gl, gz / gl];
29
+ // Cavity: walk out along the normal; a crevice brings other surface closer than the walk.
30
+ let occ = 0;
31
+ for (let i = 1; i <= 3; i++) {
32
+ const t = (d * i) / 3;
33
+ occ += Math.max(0, t - field([x + n[0] * t, y + n[1] * t, z + n[2] * t])) / t / i;
34
+ }
35
+ const cav = Math.min(1, occ * 2.5) * k;
36
+ const up = n[2] * k;
37
+ const lift = up > 0 ? 1 + up * 0.22 : 1 + up * 0.3;
38
+ const warm: Vec3 =
39
+ up > 0 ? [1 + up * 0.12, 1 + up * 0.05, 1 - up * 0.15] : [1 + up * 0.1, 1, 1 - up * 0.12];
40
+ const dark = 1 - cav * 0.55;
41
+ const lum = (c[0] + c[1] + c[2]) / 3;
42
+ // Saturate into the crevice: push each channel away from the grey by the cavity.
43
+ const sat = 1 + cav * 0.35;
44
+ return [
45
+ clamp((lum + (c[0] - lum) * sat) * dark * lift * warm[0]),
46
+ clamp((lum + (c[1] - lum) * sat) * dark * lift * warm[1]),
47
+ clamp((lum + (c[2] - lum) * sat) * dark * lift * warm[2]),
48
+ ];
49
+ };
50
+ }
51
+
52
+ const clamp = (v: number): number => Math.min(Math.max(v, 0), 1);
@@ -0,0 +1,230 @@
1
+ // The bust's anatomy, cut and plinth: ribcage, pectorals, deltoids, the trapezius sloping from
2
+ // behind the ear to the shoulder (it is what joins a head to a body), clavicles and the sternal
3
+ // notch — all scaled by `bust.mass` (0 bony … 1 brute) — trimmed by the classic collectible-bust
4
+ // cut (a U under the chest rising to the arms) and set on a turned socle + disc plinth.
5
+ // Head units, mirrored across Y; `torsoForms(f, g)` grows every form by g for clothing shells.
6
+
7
+ import type { Vec3 } from '../core/index.ts';
8
+ import { type Field, type SculptEdit, sd } from '../primitives/index.ts';
9
+ import { alongX } from './head-geom.ts';
10
+ import type { HeadLayout } from './head-layout.ts';
11
+ import type { HeadParams } from './head-params.ts';
12
+
13
+ /** Where the bust sits relative to the head: centre x, shoulder half-span, base, neck root… */
14
+ export interface BustFrame {
15
+ readonly cx: number;
16
+ readonly s: number;
17
+ /** Reference z the torso is laid out from (the floor lifted by the build's hunch). */
18
+ readonly b: number;
19
+ /** The asset's floor: bottom of the plinth. */
20
+ readonly floor: number;
21
+ /** Neck root (top of the sternum), z. */
22
+ readonly nz: number;
23
+ readonly m: number;
24
+ /** Neck radius. */
25
+ readonly n: number;
26
+ /** Where the trapezius meets the neck, z (high behind the ear on a brute). */
27
+ readonly trapZ: number;
28
+ /** Top of the plinth = bottom of the chest cut, z. */
29
+ readonly cutZ: number;
30
+ }
31
+
32
+ const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
33
+ const mid = (a: Vec3, b: Vec3): Vec3 => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
34
+ const dist = (a: Vec3, b: Vec3): number => Math.hypot(...sub(a, b));
35
+
36
+ /** Plinth height, head units. */
37
+ const PLINTH = 0.17;
38
+
39
+ export function frameOf(p: HeadParams, l: HeadLayout): BustFrame {
40
+ const m = p.bust.mass ?? 0.5;
41
+ // A brute's shoulders ride up round its ears (the hunch): lift the torso, lengthen the socle.
42
+ const b = l.base + 0.06 + 0.1 * m;
43
+ const nz = b + 0.6;
44
+ return {
45
+ cx: l.cranium.at[0] + p.jaw.muzzle * 0.3,
46
+ s: p.bust.shoulders,
47
+ b,
48
+ floor: l.base,
49
+ nz,
50
+ m,
51
+ n: p.neck.radius,
52
+ trapZ: nz + 0.12 + (l.hinge[1] - 0.2 - nz - 0.12) * m,
53
+ cutZ: l.base + PLINTH + 0.06 + 0.1 * m,
54
+ };
55
+ }
56
+
57
+ /** Anatomy volumes (plain adds), every one grown by `g`. */
58
+ export function torsoForms(f: BustFrame, g = 0): SculptEdit[] {
59
+ const { cx, s, b, m, n } = f;
60
+ const grow = (r: Vec3): Vec3 => [r[0] + g, r[1] + g, r[2] + g];
61
+ const top: Vec3 = [cx - 0.12, n * 0.5, f.trapZ];
62
+ const acromion: Vec3 = [cx - 0.05, s * 0.78, b + 0.62 + 0.05 * m];
63
+ return [
64
+ // Ribcage: the barrel everything hangs on.
65
+ {
66
+ shape: 'ellipsoid',
67
+ at: [cx - 0.05, 0, b + 0.36],
68
+ size: grow([0.25 + 0.07 * m, s * 0.66, 0.38]),
69
+ },
70
+ // Pectorals: two slabs angled up to the shoulder; their lower edge is the chest's shadow line.
71
+ {
72
+ shape: 'ellipsoid',
73
+ at: [cx + 0.1 + 0.05 * m, s * 0.27, b + 0.46 + 0.02 * m],
74
+ size: grow([0.09 + 0.06 * m, s * 0.27, 0.11 + 0.03 * m]),
75
+ rotate: [-12, 0, 16],
76
+ blend: 0.05,
77
+ },
78
+ // Deltoids: a cap over each shoulder joint.
79
+ {
80
+ shape: 'ellipsoid',
81
+ at: [cx - 0.03, s * 0.8, b + 0.5 + 0.03 * m],
82
+ size: grow([0.13 + 0.06 * m, 0.11 + 0.05 * m, 0.16 + 0.05 * m]),
83
+ rotate: [-18, 0, 0],
84
+ blend: 0.08,
85
+ },
86
+ // Trapezius: a broad flat sheet from the neck behind the ear down to the acromion — the slope
87
+ // of a brute (local X along the slope, Y front-back, Z its thickness).
88
+ {
89
+ shape: 'ellipsoid',
90
+ at: mid(top, acromion),
91
+ size: [dist(top, acromion) / 2 + 0.08 + g, 0.11 + 0.12 * m + g, 0.05 + 0.1 * m + g],
92
+ rotate: alongX(sub(acromion, top)),
93
+ blend: 0.08 + 0.08 * m,
94
+ },
95
+ // Upper arm: a stump the bust cut ends, filling the shoulder under any pauldron.
96
+ {
97
+ shape: 'capsule',
98
+ at: [cx - 0.04, s * 0.8, b + 0.55],
99
+ to: [cx - 0.02, s * 0.9, b + 0.05],
100
+ size: 0.12 + 0.05 * m + g,
101
+ blend: 0.06,
102
+ },
103
+ // Clavicles: a bony ridge from the sternal notch out to the shoulder (clothes drape over it).
104
+ {
105
+ shape: 'capsule',
106
+ at: [cx + 0.2 + 0.04 * m, 0.045, f.nz - 0.03],
107
+ to: [cx + 0.0, s * 0.68, b + 0.6 + 0.04 * m],
108
+ size: 0.02 + 0.006 * (1 - m) + g,
109
+ blend: 0.035,
110
+ },
111
+ // Latissimus: widens the back under the armpit (a V-taper on a brute).
112
+ {
113
+ shape: 'ellipsoid',
114
+ at: [cx - 0.1, s * 0.55, b + 0.3],
115
+ size: grow([0.17 + 0.05 * m, 0.12 + 0.1 * m, 0.2]),
116
+ blend: 0.08,
117
+ },
118
+ // Upper back: the hump of trapezius and rhomboids behind the neck.
119
+ {
120
+ shape: 'ellipsoid',
121
+ at: [cx - 0.18, 0, f.nz + 0.04 + 0.08 * m],
122
+ size: grow([0.12 + 0.06 * m, s * 0.32, 0.16 + 0.12 * m]),
123
+ blend: 0.1,
124
+ },
125
+ ];
126
+ }
127
+
128
+ /** Signed distance to the bust-cut region (negative inside): ≈ 0 on the cut faces. */
129
+ export function cutField(f: BustFrame): Field {
130
+ const rz = 0.64;
131
+ const ry = f.s * 1.08;
132
+ const cz = f.cutZ + rz;
133
+ const e = sd.ellipsoid([f.cx, 0, cz], [1.6, ry, rz]);
134
+ const box = sd.box([f.cx, 0, cz + 1], [3.2, ry * 2, 2]);
135
+ return (q) => Math.min(e(q), box(q));
136
+ }
137
+
138
+ /** Keeps a group to the bust silhouette: the U-cut under the chest, rising to cut the arms. */
139
+ export function bustCut(f: BustFrame, g = 0): SculptEdit {
140
+ const rz = 0.64;
141
+ const ry = f.s * 1.08 + g;
142
+ // (cutField mirrors these numbers.)
143
+ const cz = f.cutZ + rz - g;
144
+ return {
145
+ group: [
146
+ { shape: 'ellipsoid', at: [f.cx, 0, cz], size: [1.6, ry, rz] },
147
+ { shape: 'roundBox', at: [f.cx, 0, cz + 1], size: [3.2, ry * 2, 2] },
148
+ ],
149
+ op: 'intersect',
150
+ blend: 0.012,
151
+ };
152
+ }
153
+
154
+ /** The bare body: anatomy in `color`, sternal notch and groove, then the bust cut. */
155
+ export function bodyEdits(p: HeadParams, f: BustFrame, color: Vec3): SculptEdit[] {
156
+ const { cx, b, m, nz } = f;
157
+ const shade = p.colors.shade;
158
+ return [
159
+ {
160
+ group: [
161
+ ...torsoForms(f),
162
+ {
163
+ shape: 'sphere',
164
+ at: [cx + 0.24 + 0.04 * m, 0, nz - 0.04],
165
+ size: 0.035,
166
+ op: 'subtract',
167
+ blend: 0.04,
168
+ color: shade,
169
+ },
170
+ {
171
+ shape: 'capsule',
172
+ at: [cx + 0.26 + 0.06 * m, 0, b + 0.5],
173
+ to: [cx + 0.26 + 0.05 * m, 0, b + 0.22],
174
+ size: 0.01 + 0.006 * m,
175
+ op: 'subtract',
176
+ blend: 0.04,
177
+ color: shade,
178
+ },
179
+ bustCut(f),
180
+ ],
181
+ color,
182
+ },
183
+ ];
184
+ }
185
+
186
+ /** Solid cylinder about Z from z0 to z1 (a capsule cut by a slab). */
187
+ const cylinder = (at: Vec3, r: number, z0: number, z1: number): SculptEdit => ({
188
+ group: [
189
+ { shape: 'capsule', at: [at[0], at[1], z0 - r], to: [at[0], at[1], z1 + r], size: r },
190
+ {
191
+ shape: 'roundBox',
192
+ at: [at[0], at[1], (z0 + z1) / 2],
193
+ size: [4, 4, z1 - z0],
194
+ op: 'intersect',
195
+ },
196
+ ],
197
+ });
198
+
199
+ /** A turned plinth: wide disc with a bead moulding, then a narrower socle up to the chest cut.
200
+ * Both start below the floor, so the bust's flat base cut alone makes the underside. */
201
+ /** `minT`: floor for the disc's thickness (≈ a cell), so far LODs keep the bust on the ground. */
202
+ export function plinthEdits(p: HeadParams, f: BustFrame, minT = 0): SculptEdit[] {
203
+ const c = p.colors.plinth ?? ([0.16, 0.15, 0.15] as Vec3);
204
+ const light: Vec3 = [c[0] * 1.35, c[1] * 1.35, c[2] * 1.35];
205
+ const x = f.cx - 0.04;
206
+ const R = 0.3 + 0.08 * f.s;
207
+ // Coarse cells would lose a thin disc: one solid drum from the floor to the chest cut instead.
208
+ if (minT > 0.07)
209
+ return [
210
+ { ...cylinder([x, 0, 0], R * 1.05, f.floor - 0.1, f.cutZ + 0.02), color: c, mirror: false },
211
+ ];
212
+ const disc = f.floor + 0.07;
213
+ return [
214
+ { ...cylinder([x, 0, 0], R, f.floor - 0.1, disc), color: c, mirror: false },
215
+ {
216
+ shape: 'torus',
217
+ at: [x, 0, disc],
218
+ size: [R - 0.03, 0.025],
219
+ color: light,
220
+ blend: 0.01,
221
+ mirror: false,
222
+ },
223
+ {
224
+ ...cylinder([x, 0, 0], R * 0.62, disc, f.cutZ + 0.02),
225
+ color: c,
226
+ blend: 0.02,
227
+ mirror: false,
228
+ },
229
+ ];
230
+ }
package/src/kinds/head.ts CHANGED
@@ -26,6 +26,7 @@ import { type HeadLayout, headLayout } from './head-layout.ts';
26
26
  import { bustMesh, eyeMesh, type HeadEditSet, headAndJaw } from './head-mesh.ts';
27
27
  import { checkHead, type HeadParams } from './head-params.ts';
28
28
  import { HEADS, type HeadPreset } from './head-presets.ts';
29
+ import { cutField, frameOf } from './head-torso.ts';
29
30
 
30
31
  /** Overrides: top-level keys replace, nested objects merge one level (`{ jaw: { underbite: 0.1 } }`). */
31
32
  export type HeadOverrides = { readonly [K in keyof HeadParams]?: Partial<HeadParams[K]> };
@@ -78,6 +79,9 @@ function seatEyes(p: HeadParams, l: HeadLayout): HeadLayout {
78
79
  return { ...l, eye: [x - r * 0.7 - p.brow.overhang * 0.3, ey, ez] };
79
80
  }
80
81
 
82
+ /** The bust's cell as a multiple of the head's (the bust carries less detail). */
83
+ const BUST_CELL = 1.6;
84
+
81
85
  /** Sculpt cell edge per LOD, in head units (× height = metres). */
82
86
  export const HEAD_RES = [0.02, 0.045, 0.14] as const;
83
87
 
@@ -94,7 +98,7 @@ export function headEdits(
94
98
  return {
95
99
  layout: l,
96
100
  skull: [
97
- ...skullEdits(p, l),
101
+ ...skullEdits(p, l, res),
98
102
  ...hair,
99
103
  ...browEdits(p, l),
100
104
  ...beardEdits(p, l),
@@ -103,7 +107,8 @@ export function headEdits(
103
107
  ],
104
108
  ears: earEdits(p, l, res * 0.7),
105
109
  tusks: tuskEdits(p, l, res * 0.45),
106
- bust: bustEdits(p, l),
110
+ bust: bustEdits(p, l, res * BUST_CELL),
111
+ bustCut: cutField(frameOf(p, l)),
107
112
  };
108
113
  }
109
114
 
@@ -114,7 +119,7 @@ export function head(ctx: BuildContext, o: HeadOptions = {}): HeadInfo {
114
119
  const H = p.height;
115
120
  const res = HEAD_RES[ctx.lod];
116
121
  const { head: hm, jaw: jm } = headAndJaw(p, l, set, res);
117
- const bm = bustMesh(p, set, res * 1.6);
122
+ const bm = bustMesh(p, set, res * BUST_CELL);
118
123
  const eye = eyeMesh(p, l.eyeR, l.eyeR / ctx.pick([7, 3.5, 2.2]));
119
124
  const [ox, oy, oz] = o.at ?? [0, 0, 0];
120
125
  const toWorld = (m: Mesh, at: Vec3 = [0, 0, 0]): Mesh =>
@@ -0,0 +1,41 @@
1
+ // Ready-made CC0 image materials (opt-in, never registered by default, so the repo paints offline).
2
+ // Register with `defineMaterial(CC0_WOOD_PLANKS_GREY)`, fetch once with `bun run textures`, then
3
+ // `provideTextures(await loadTextureCache())` before painting. Hashes pin the exact bytes.
4
+
5
+ import type { MaterialDef } from './material-def.ts';
6
+
7
+ const PH = 'https://dl.polyhaven.org/file/ph-assets/Textures/png/1k/wood_planks_grey';
8
+
9
+ /** Poly Haven "Wood Planks Grey" by Rob Tuytel (CC0), 1.5 m square, 1k PNG maps. */
10
+ export const CC0_WOOD_PLANKS_GREY: MaterialDef = {
11
+ id: 'ph_wood_planks_grey',
12
+ kind: 'flat',
13
+ scaleMetres: 0.6,
14
+ roughness: 0.85,
15
+ description: 'Photo-scanned weathered grey planks (Poly Haven, CC0).',
16
+ sources: ['https://polyhaven.com/a/wood_planks_grey'],
17
+ texture: {
18
+ source: 'polyhaven',
19
+ id: 'wood_planks_grey',
20
+ license: 'CC0-1.0',
21
+ page: 'https://polyhaven.com/a/wood_planks_grey',
22
+ author: 'Rob Tuytel',
23
+ metres: [1.5, 1.5],
24
+ maps: {
25
+ albedo: {
26
+ url: `${PH}/wood_planks_grey_diff_1k.png`,
27
+ sha256: '3e6aeb842edd813759fdc96a6ffe6b695c888280f42501de589442311749ac5d',
28
+ },
29
+ normal: {
30
+ url: `${PH}/wood_planks_grey_nor_gl_1k.png`,
31
+ sha256: 'a9f2ad602eca2d59e84f203b1087e819e62ae517532bb25f7eb02a13c826e08b',
32
+ },
33
+ arm: {
34
+ url: `${PH}/wood_planks_grey_arm_1k.png`,
35
+ sha256: '9b81e9a0b4f9db0a920c4c2ce8ff13e0411b3624d4de455acdabe41e3fafe20e',
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export const CC0_MATERIALS: readonly MaterialDef[] = [CC0_WOOD_PLANKS_GREY];