@broberg/bodymap 0.7.1 → 0.9.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.
package/dist/three.d.cts CHANGED
@@ -97,14 +97,41 @@ interface BodymapUiColors {
97
97
  text?: string;
98
98
  /** Secondary text — section labels, the empty-state hint. */
99
99
  mutedText?: string;
100
- /** Panel background. */
100
+ /** Panel background — the card, its buttons, the empty hint. */
101
101
  panelBg?: string;
102
+ /**
103
+ * THE STAGE: the fullscreen backdrop, the canvas frame, and the three.js
104
+ * scene behind the body. A different surface from `panelBg` with an opposite
105
+ * need — see F052.29, where one field paints both and no value is right for
106
+ * either.
107
+ *
108
+ * 3D only. The 2D renderer has no canvas and no fullscreen, so it ignores
109
+ * this; it is declared here because a stage is a shared idea, not because
110
+ * every renderer has one.
111
+ *
112
+ * Defaults to {@link STAGE_BG}. Pass `'#fff'` for the pre-0.8.0 white
113
+ * fullscreen backdrop.
114
+ */
115
+ stageBg?: string;
102
116
  /** Panel + control borders. */
103
117
  border?: string;
104
118
  /** Background behind the region-code badge. */
105
119
  badgeBg?: string;
106
120
  /** The destructive action (remove a marked region). */
107
121
  danger?: string;
122
+ /** Border on the destructive control. Its TEXT was already themeable and its
123
+ * border was not — half a control. (F052.30) */
124
+ dangerBorder?: string;
125
+ /**
126
+ * THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.
127
+ *
128
+ * NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the
129
+ * colour of an active button. Collapsing them would stop a consumer having a
130
+ * teal mark and a navy button, which is a normal thing to want. (F052.30)
131
+ */
132
+ accent?: string;
133
+ /** Text ON the accent. Check it against your accent — see the README. */
134
+ accentText?: string;
108
135
  }
109
136
  type BodyView = "front" | "back" | "left" | "right";
110
137
  /** Side in the serialized report — a midline region (no side) becomes "center". */
@@ -193,6 +220,40 @@ interface BodyMap3DUiLabels {
193
220
  * PainReport is unchanged.
194
221
  */
195
222
  declare function seamBlend(nearestSq: number, secondSq: number): number;
223
+ /**
224
+ * F052.23 — how much of the NEIGHBOUR region's colour a vertex takes.
225
+ *
226
+ * Extracted from the paint loop for one reason: happy-dom cannot run WebGL, so
227
+ * the fix that put the seam blend behind an opt-in had no automated guard at
228
+ * all. A regression here is a WCAG failure on a public-health surface (measured:
229
+ * 4.71:1 without the blend, 3.59:1 with it, averaged over a small mark), which
230
+ * is not the kind of thing that should rely on someone re-measuring by hand.
231
+ *
232
+ * The invariant worth asserting is not "off returns 0 for this blend value" —
233
+ * it is that off returns 0 for EVERY blend value, so the shipped default can
234
+ * never mix body colour into a mark.
235
+ */
236
+ declare function seamWeight(seamEnabled: boolean, blend: number): number;
237
+ /**
238
+ * F052.25 — recompute vertex normals ONLY when the file shipped none.
239
+ *
240
+ * Same reason as above: this single condition WAS the "lines on the body" bug,
241
+ * and it lived on the one code path the unit suite cannot execute. Takes a
242
+ * structural slice rather than a THREE.BufferGeometry so it is testable without
243
+ * a GL context.
244
+ *
245
+ * It performs the call as well as the check, deliberately. A predicate alone
246
+ * would let a test pass while the call site did the opposite with it — and what
247
+ * the acceptance criterion asks for is the EFFECT: a model that ships normals
248
+ * must come out the other side with those same normals, untouched.
249
+ *
250
+ * Returns true when it computed (the model had none, and would otherwise render
251
+ * flat), false when it left the authored normals alone.
252
+ */
253
+ declare function ensureNormals(geo: {
254
+ getAttribute(name: string): unknown;
255
+ computeVertexNormals(): void;
256
+ }): boolean;
196
257
  interface BodyMap3DProps {
197
258
  /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */
198
259
  models: BodyMap3DModels;
@@ -264,4 +325,4 @@ interface BodyMap3DProps {
264
325
  }
265
326
  declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
266
327
 
267
- export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, seamBlend, serializeReport };
328
+ export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
package/dist/three.d.ts CHANGED
@@ -97,14 +97,41 @@ interface BodymapUiColors {
97
97
  text?: string;
98
98
  /** Secondary text — section labels, the empty-state hint. */
99
99
  mutedText?: string;
100
- /** Panel background. */
100
+ /** Panel background — the card, its buttons, the empty hint. */
101
101
  panelBg?: string;
102
+ /**
103
+ * THE STAGE: the fullscreen backdrop, the canvas frame, and the three.js
104
+ * scene behind the body. A different surface from `panelBg` with an opposite
105
+ * need — see F052.29, where one field paints both and no value is right for
106
+ * either.
107
+ *
108
+ * 3D only. The 2D renderer has no canvas and no fullscreen, so it ignores
109
+ * this; it is declared here because a stage is a shared idea, not because
110
+ * every renderer has one.
111
+ *
112
+ * Defaults to {@link STAGE_BG}. Pass `'#fff'` for the pre-0.8.0 white
113
+ * fullscreen backdrop.
114
+ */
115
+ stageBg?: string;
102
116
  /** Panel + control borders. */
103
117
  border?: string;
104
118
  /** Background behind the region-code badge. */
105
119
  badgeBg?: string;
106
120
  /** The destructive action (remove a marked region). */
107
121
  danger?: string;
122
+ /** Border on the destructive control. Its TEXT was already themeable and its
123
+ * border was not — half a control. (F052.30) */
124
+ dangerBorder?: string;
125
+ /**
126
+ * THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.
127
+ *
128
+ * NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the
129
+ * colour of an active button. Collapsing them would stop a consumer having a
130
+ * teal mark and a navy button, which is a normal thing to want. (F052.30)
131
+ */
132
+ accent?: string;
133
+ /** Text ON the accent. Check it against your accent — see the README. */
134
+ accentText?: string;
108
135
  }
109
136
  type BodyView = "front" | "back" | "left" | "right";
110
137
  /** Side in the serialized report — a midline region (no side) becomes "center". */
@@ -193,6 +220,40 @@ interface BodyMap3DUiLabels {
193
220
  * PainReport is unchanged.
194
221
  */
195
222
  declare function seamBlend(nearestSq: number, secondSq: number): number;
223
+ /**
224
+ * F052.23 — how much of the NEIGHBOUR region's colour a vertex takes.
225
+ *
226
+ * Extracted from the paint loop for one reason: happy-dom cannot run WebGL, so
227
+ * the fix that put the seam blend behind an opt-in had no automated guard at
228
+ * all. A regression here is a WCAG failure on a public-health surface (measured:
229
+ * 4.71:1 without the blend, 3.59:1 with it, averaged over a small mark), which
230
+ * is not the kind of thing that should rely on someone re-measuring by hand.
231
+ *
232
+ * The invariant worth asserting is not "off returns 0 for this blend value" —
233
+ * it is that off returns 0 for EVERY blend value, so the shipped default can
234
+ * never mix body colour into a mark.
235
+ */
236
+ declare function seamWeight(seamEnabled: boolean, blend: number): number;
237
+ /**
238
+ * F052.25 — recompute vertex normals ONLY when the file shipped none.
239
+ *
240
+ * Same reason as above: this single condition WAS the "lines on the body" bug,
241
+ * and it lived on the one code path the unit suite cannot execute. Takes a
242
+ * structural slice rather than a THREE.BufferGeometry so it is testable without
243
+ * a GL context.
244
+ *
245
+ * It performs the call as well as the check, deliberately. A predicate alone
246
+ * would let a test pass while the call site did the opposite with it — and what
247
+ * the acceptance criterion asks for is the EFFECT: a model that ships normals
248
+ * must come out the other side with those same normals, untouched.
249
+ *
250
+ * Returns true when it computed (the model had none, and would otherwise render
251
+ * flat), false when it left the authored normals alone.
252
+ */
253
+ declare function ensureNormals(geo: {
254
+ getAttribute(name: string): unknown;
255
+ computeVertexNormals(): void;
256
+ }): boolean;
196
257
  interface BodyMap3DProps {
197
258
  /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */
198
259
  models: BodyMap3DModels;
@@ -264,4 +325,4 @@ interface BodyMap3DProps {
264
325
  }
265
326
  declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
266
327
 
267
- export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, seamBlend, serializeReport };
328
+ export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
package/dist/three.js CHANGED
@@ -81,13 +81,24 @@ function emitFeedback(outcome, region, opts = {}) {
81
81
  if (opts.haptics === false) return "skipped";
82
82
  return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav);
83
83
  }
84
+ var STAGE_BG = "#0e1424";
84
85
  var defaultUi = {
85
86
  text: "#1e293b",
86
87
  mutedText: "#475569",
87
88
  panelBg: "#fff",
89
+ stageBg: STAGE_BG,
88
90
  border: "#e2e8f0",
89
91
  badgeBg: "#f1f5f9",
90
- danger: "#dc2626"
92
+ danger: "#dc2626",
93
+ dangerBorder: "#f6c9c9",
94
+ // F052.30 — was #0e8f8a, which measured 3.95:1 against white. AA needs 4.5,
95
+ // and this is the SELECTED intensity button on a surface where AA is a legal
96
+ // duty. It shipped from 0.2.0 and no check could see it: the colour was an
97
+ // inline literal, and the F052.19 contrast sweep only read this object.
98
+ // #0c7d77 is the same teal a shade deeper — 4.98:1, with headroom rather than
99
+ // sitting on the 4.5 boundary where any later tweak reopens it.
100
+ accent: "#0c7d77",
101
+ accentText: "#fff"
91
102
  };
92
103
  function uiColors(palette) {
93
104
  return { ...defaultUi, ...palette?.ui ?? {} };
@@ -246,6 +257,14 @@ function seamBlend(nearestSq, secondSq) {
246
257
  if (t >= 1) return 0.5;
247
258
  return t * t * (3 - 2 * t) * 0.5;
248
259
  }
260
+ function seamWeight(seamEnabled, blend) {
261
+ return seamEnabled ? blend : 0;
262
+ }
263
+ function ensureNormals(geo) {
264
+ if (geo.getAttribute("normal")) return false;
265
+ geo.computeVertexNormals();
266
+ return true;
267
+ }
249
268
  var ANCHOR_KEYS = Object.keys(ANCHORS);
250
269
  for (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];
251
270
  function mergeLabels(locale, overrides) {
@@ -266,8 +285,20 @@ function webglAvailable() {
266
285
  return false;
267
286
  }
268
287
  }
269
- var btn = { font: "inherit", cursor: "pointer", borderRadius: 8, border: "1px solid #e2e8f0", background: "#fff", padding: "6px 9px" };
270
- var seg = (on) => ({ ...btn, background: on ? "#0e8f8a" : "#fff", color: on ? "#fff" : "#1e293b", fontWeight: 600 });
288
+ var btn = (c) => ({
289
+ font: "inherit",
290
+ cursor: "pointer",
291
+ borderRadius: 8,
292
+ border: `1px solid ${c.border}`,
293
+ background: c.panelBg,
294
+ padding: "6px 9px"
295
+ });
296
+ var seg = (c, on) => ({
297
+ ...btn(c),
298
+ background: on ? c.accent : c.panelBg,
299
+ color: on ? c.accentText : c.text,
300
+ fontWeight: 600
301
+ });
271
302
  function BodyMap3D(props) {
272
303
  const {
273
304
  models,
@@ -331,6 +362,8 @@ function BodyMap3D(props) {
331
362
  setReadyRef.current = setReady;
332
363
  const seamRef = useRef(seam);
333
364
  seamRef.current = seam;
365
+ const stageRef = useRef(chrome.stageBg);
366
+ stageRef.current = chrome.stageBg;
334
367
  const [internalFs, setInternalFs] = useState(false);
335
368
  const isFullscreen = fullscreenProp ?? internalFs;
336
369
  const setFullscreen = (v) => {
@@ -347,7 +380,7 @@ function BodyMap3D(props) {
347
380
  if (!el) return;
348
381
  let W = el.clientWidth || 520, H = el.clientHeight || 600;
349
382
  const scene = new THREE.Scene();
350
- scene.background = new THREE.Color(922660);
383
+ scene.background = new THREE.Color(stageRef.current);
351
384
  const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);
352
385
  camera.position.set(0, 1.05, 4.4);
353
386
  let renderer;
@@ -430,7 +463,7 @@ function BodyMap3D(props) {
430
463
  };
431
464
  for (let i = 0; i < vertexRegion.length; i++) {
432
465
  tmp.copy(col(vertexRegion[i]));
433
- const w = seamRef.current ? vertexBlend[i] : 0;
466
+ const w = seamWeight(seamRef.current, vertexBlend[i]);
434
467
  if (w > 0) {
435
468
  tmp2.copy(col(vertexNeighbour[i]));
436
469
  tmp.lerp(tmp2, w);
@@ -440,6 +473,7 @@ function BodyMap3D(props) {
440
473
  colorAttr.needsUpdate = true;
441
474
  };
442
475
  const refresh = () => {
476
+ scene.background = new THREE.Color(stageRef.current);
443
477
  paint();
444
478
  renderFrame();
445
479
  };
@@ -464,7 +498,7 @@ function BodyMap3D(props) {
464
498
  });
465
499
  if (bodyMesh) {
466
500
  const geo = bodyMesh.geometry;
467
- if (!geo.getAttribute("normal")) geo.computeVertexNormals();
501
+ ensureNormals(geo);
468
502
  const pos = geo.getAttribute("position");
469
503
  const n = pos.count;
470
504
  const cols = new Float32Array(n * 3);
@@ -654,7 +688,7 @@ function BodyMap3D(props) {
654
688
  "data-fullscreen": isFullscreen ? "true" : void 0,
655
689
  style: {
656
690
  fontFamily: "system-ui, sans-serif",
657
- color: "#1e293b",
691
+ color: chrome.text,
658
692
  // A viewport fill, not the Fullscreen API — see the `fullscreen` prop.
659
693
  // `fixed`+`inset:0` behaves the same on iOS Safari, where element
660
694
  // fullscreen does not exist at all, as it does everywhere else.
@@ -662,7 +696,12 @@ function BodyMap3D(props) {
662
696
  position: "fixed",
663
697
  inset: 0,
664
698
  zIndex: 2147483e3,
665
- background: chrome.panelBg,
699
+ // THE STAGE, not the panel. This used to be `chrome.panelBg`,
700
+ // the same field that paints the picker card — so a consumer with
701
+ // a dark canvas got a white void around a dark body in fullscreen,
702
+ // and could not fix it: darkening panelBg hid the 0-10 buttons.
703
+ // One field, two surfaces, opposite needs. (F052.29)
704
+ background: chrome.stageBg,
666
705
  // F052.27 — a fixed inset:0 surface in a standalone/Capacitor
667
706
  // webview covers the STATUS BAR too, so a control laid out at the
668
707
  // top lands on the clock and the battery. Measured by fd-sundhed
@@ -681,8 +720,8 @@ function BodyMap3D(props) {
681
720
  },
682
721
  children: [
683
722
  showSexToggle && /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
684
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
685
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
723
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(chrome, sex === "male"), children: UI.male }),
724
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(chrome, sex === "female"), children: UI.female })
686
725
  ] }) }),
687
726
  /* @__PURE__ */ jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
688
727
  ready && /* @__PURE__ */ jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
@@ -695,7 +734,7 @@ function BodyMap3D(props) {
695
734
  "aria-label": isFullscreen ? UI.collapse ?? "Close" : UI.expand ?? "Expand",
696
735
  onClick: () => setFullscreen(!isFullscreen),
697
736
  style: {
698
- ...btn,
737
+ ...btn(chrome),
699
738
  color: chrome.text,
700
739
  borderColor: chrome.border,
701
740
  background: chrome.panelBg,
@@ -705,7 +744,14 @@ function BodyMap3D(props) {
705
744
  }
706
745
  ) }),
707
746
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap", ...isFullscreen ? { flex: "1 1 auto", minHeight: 0 } : null }, children: [
708
- unsupported ? /* @__PURE__ */ jsx("div", { "data-testid": "bodymap3d-unsupported", style: { flex: "1 1 520px", minWidth: 320, height: canvasH, borderRadius: 16, background: "#0e1424", color: "#cbd5e1", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, textAlign: "center", fontSize: 14 }, children: "3D kr\xE6ver WebGL, som ikke er tilg\xE6ngeligt her." }) : /* @__PURE__ */ jsx(
747
+ unsupported ? /* @__PURE__ */ jsx(
748
+ "div",
749
+ {
750
+ "data-testid": "bodymap3d-unsupported",
751
+ style: { flex: "1 1 520px", minWidth: 320, height: canvasH, borderRadius: 16, background: chrome.panelBg, color: chrome.mutedText, border: `1px solid ${chrome.border}`, display: "flex", alignItems: "center", justifyContent: "center", padding: 24, textAlign: "center", fontSize: 14 },
752
+ children: "3D kr\xE6ver WebGL, som ikke er tilg\xE6ngeligt her."
753
+ }
754
+ ) : /* @__PURE__ */ jsx(
709
755
  "div",
710
756
  {
711
757
  ref: mountRef,
@@ -720,7 +766,7 @@ function BodyMap3D(props) {
720
766
  minHeight: isFullscreen ? 0 : void 0,
721
767
  borderRadius: 16,
722
768
  overflow: "hidden",
723
- background: "#0e1424",
769
+ background: chrome.stageBg,
724
770
  touchAction: "none"
725
771
  }
726
772
  }
@@ -739,16 +785,16 @@ function BodyMap3D(props) {
739
785
  "data-testid": "bodymap3d-close",
740
786
  "aria-label": L.close,
741
787
  onClick: () => setSelected(null),
742
- style: { ...btn, width: 32, height: 32, padding: 0, fontSize: 20, lineHeight: 1, color: chrome.mutedText, borderColor: chrome.border, background: chrome.panelBg },
788
+ style: { ...btn(chrome), width: 32, height: 32, padding: 0, fontSize: 20, lineHeight: 1, color: chrome.mutedText, borderColor: chrome.border, background: chrome.panelBg },
743
789
  children: "\xD7"
744
790
  }
745
791
  )
746
792
  ] }),
747
793
  /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
748
- /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn, display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 30, height: 30, padding: 0, background: current?.intensity === i ? "#0e8f8a" : "#fff", color: current?.intensity === i ? "#fff" : "#1e293b" }, children: i }, i)) }),
794
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn(chrome), display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 30, height: 30, padding: 0, background: current?.intensity === i ? chrome.accent : chrome.panelBg, color: current?.intensity === i ? chrome.accentText : chrome.text }, children: i }, i)) }),
749
795
  /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
750
- /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 }, children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-type-${t}`, onClick: () => setPain(region.key, current?.intensity ?? 5, t), style: { ...btn, borderRadius: 999, padding: "6px 12px", background: current?.type === t ? "#1e293b" : "#fff", color: current?.type === t ? "#fff" : "#64748b" }, children: L.qualities[t] ?? t }, t)) }),
751
- current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
796
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 }, children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-type-${t}`, onClick: () => setPain(region.key, current?.intensity ?? 5, t), style: { ...btn(chrome), borderRadius: 999, padding: "6px 12px", background: current?.type === t ? chrome.text : chrome.panelBg, color: current?.type === t ? chrome.panelBg : chrome.mutedText }, children: L.qualities[t] ?? t }, t)) }),
797
+ current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn(chrome), color: chrome.danger, borderColor: chrome.dangerBorder }, children: L.remove })
752
798
  ] }) : showEmptyHint ? /* @__PURE__ */ jsx("div", { "data-testid": "bodymap3d-empty", style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg, color: chrome.mutedText, fontSize: 13.5 }, children: UI.hoverHint }) : null })
753
799
  ] })
754
800
  ]
@@ -756,6 +802,6 @@ function BodyMap3D(props) {
756
802
  );
757
803
  }
758
804
 
759
- export { BodyMap3D, seamBlend, serializeReport };
805
+ export { BodyMap3D, ensureNormals, seamBlend, seamWeight, serializeReport };
760
806
  //# sourceMappingURL=three.js.map
761
807
  //# sourceMappingURL=three.js.map