@broberg/bodymap 0.7.1 → 0.8.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,8 +97,22 @@ 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. */
@@ -193,6 +207,40 @@ interface BodyMap3DUiLabels {
193
207
  * PainReport is unchanged.
194
208
  */
195
209
  declare function seamBlend(nearestSq: number, secondSq: number): number;
210
+ /**
211
+ * F052.23 — how much of the NEIGHBOUR region's colour a vertex takes.
212
+ *
213
+ * Extracted from the paint loop for one reason: happy-dom cannot run WebGL, so
214
+ * the fix that put the seam blend behind an opt-in had no automated guard at
215
+ * all. A regression here is a WCAG failure on a public-health surface (measured:
216
+ * 4.71:1 without the blend, 3.59:1 with it, averaged over a small mark), which
217
+ * is not the kind of thing that should rely on someone re-measuring by hand.
218
+ *
219
+ * The invariant worth asserting is not "off returns 0 for this blend value" —
220
+ * it is that off returns 0 for EVERY blend value, so the shipped default can
221
+ * never mix body colour into a mark.
222
+ */
223
+ declare function seamWeight(seamEnabled: boolean, blend: number): number;
224
+ /**
225
+ * F052.25 — recompute vertex normals ONLY when the file shipped none.
226
+ *
227
+ * Same reason as above: this single condition WAS the "lines on the body" bug,
228
+ * and it lived on the one code path the unit suite cannot execute. Takes a
229
+ * structural slice rather than a THREE.BufferGeometry so it is testable without
230
+ * a GL context.
231
+ *
232
+ * It performs the call as well as the check, deliberately. A predicate alone
233
+ * would let a test pass while the call site did the opposite with it — and what
234
+ * the acceptance criterion asks for is the EFFECT: a model that ships normals
235
+ * must come out the other side with those same normals, untouched.
236
+ *
237
+ * Returns true when it computed (the model had none, and would otherwise render
238
+ * flat), false when it left the authored normals alone.
239
+ */
240
+ declare function ensureNormals(geo: {
241
+ getAttribute(name: string): unknown;
242
+ computeVertexNormals(): void;
243
+ }): boolean;
196
244
  interface BodyMap3DProps {
197
245
  /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */
198
246
  models: BodyMap3DModels;
@@ -264,4 +312,4 @@ interface BodyMap3DProps {
264
312
  }
265
313
  declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
266
314
 
267
- export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, seamBlend, serializeReport };
315
+ export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
package/dist/three.d.ts CHANGED
@@ -97,8 +97,22 @@ 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. */
@@ -193,6 +207,40 @@ interface BodyMap3DUiLabels {
193
207
  * PainReport is unchanged.
194
208
  */
195
209
  declare function seamBlend(nearestSq: number, secondSq: number): number;
210
+ /**
211
+ * F052.23 — how much of the NEIGHBOUR region's colour a vertex takes.
212
+ *
213
+ * Extracted from the paint loop for one reason: happy-dom cannot run WebGL, so
214
+ * the fix that put the seam blend behind an opt-in had no automated guard at
215
+ * all. A regression here is a WCAG failure on a public-health surface (measured:
216
+ * 4.71:1 without the blend, 3.59:1 with it, averaged over a small mark), which
217
+ * is not the kind of thing that should rely on someone re-measuring by hand.
218
+ *
219
+ * The invariant worth asserting is not "off returns 0 for this blend value" —
220
+ * it is that off returns 0 for EVERY blend value, so the shipped default can
221
+ * never mix body colour into a mark.
222
+ */
223
+ declare function seamWeight(seamEnabled: boolean, blend: number): number;
224
+ /**
225
+ * F052.25 — recompute vertex normals ONLY when the file shipped none.
226
+ *
227
+ * Same reason as above: this single condition WAS the "lines on the body" bug,
228
+ * and it lived on the one code path the unit suite cannot execute. Takes a
229
+ * structural slice rather than a THREE.BufferGeometry so it is testable without
230
+ * a GL context.
231
+ *
232
+ * It performs the call as well as the check, deliberately. A predicate alone
233
+ * would let a test pass while the call site did the opposite with it — and what
234
+ * the acceptance criterion asks for is the EFFECT: a model that ships normals
235
+ * must come out the other side with those same normals, untouched.
236
+ *
237
+ * Returns true when it computed (the model had none, and would otherwise render
238
+ * flat), false when it left the authored normals alone.
239
+ */
240
+ declare function ensureNormals(geo: {
241
+ getAttribute(name: string): unknown;
242
+ computeVertexNormals(): void;
243
+ }): boolean;
196
244
  interface BodyMap3DProps {
197
245
  /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */
198
246
  models: BodyMap3DModels;
@@ -264,4 +312,4 @@ interface BodyMap3DProps {
264
312
  }
265
313
  declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
266
314
 
267
- export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, seamBlend, serializeReport };
315
+ export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
package/dist/three.js CHANGED
@@ -81,10 +81,12 @@ 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
92
  danger: "#dc2626"
@@ -246,6 +248,14 @@ function seamBlend(nearestSq, secondSq) {
246
248
  if (t >= 1) return 0.5;
247
249
  return t * t * (3 - 2 * t) * 0.5;
248
250
  }
251
+ function seamWeight(seamEnabled, blend) {
252
+ return seamEnabled ? blend : 0;
253
+ }
254
+ function ensureNormals(geo) {
255
+ if (geo.getAttribute("normal")) return false;
256
+ geo.computeVertexNormals();
257
+ return true;
258
+ }
249
259
  var ANCHOR_KEYS = Object.keys(ANCHORS);
250
260
  for (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];
251
261
  function mergeLabels(locale, overrides) {
@@ -331,6 +341,8 @@ function BodyMap3D(props) {
331
341
  setReadyRef.current = setReady;
332
342
  const seamRef = useRef(seam);
333
343
  seamRef.current = seam;
344
+ const stageRef = useRef(chrome.stageBg);
345
+ stageRef.current = chrome.stageBg;
334
346
  const [internalFs, setInternalFs] = useState(false);
335
347
  const isFullscreen = fullscreenProp ?? internalFs;
336
348
  const setFullscreen = (v) => {
@@ -347,7 +359,7 @@ function BodyMap3D(props) {
347
359
  if (!el) return;
348
360
  let W = el.clientWidth || 520, H = el.clientHeight || 600;
349
361
  const scene = new THREE.Scene();
350
- scene.background = new THREE.Color(922660);
362
+ scene.background = new THREE.Color(stageRef.current);
351
363
  const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);
352
364
  camera.position.set(0, 1.05, 4.4);
353
365
  let renderer;
@@ -430,7 +442,7 @@ function BodyMap3D(props) {
430
442
  };
431
443
  for (let i = 0; i < vertexRegion.length; i++) {
432
444
  tmp.copy(col(vertexRegion[i]));
433
- const w = seamRef.current ? vertexBlend[i] : 0;
445
+ const w = seamWeight(seamRef.current, vertexBlend[i]);
434
446
  if (w > 0) {
435
447
  tmp2.copy(col(vertexNeighbour[i]));
436
448
  tmp.lerp(tmp2, w);
@@ -440,6 +452,7 @@ function BodyMap3D(props) {
440
452
  colorAttr.needsUpdate = true;
441
453
  };
442
454
  const refresh = () => {
455
+ scene.background = new THREE.Color(stageRef.current);
443
456
  paint();
444
457
  renderFrame();
445
458
  };
@@ -464,7 +477,7 @@ function BodyMap3D(props) {
464
477
  });
465
478
  if (bodyMesh) {
466
479
  const geo = bodyMesh.geometry;
467
- if (!geo.getAttribute("normal")) geo.computeVertexNormals();
480
+ ensureNormals(geo);
468
481
  const pos = geo.getAttribute("position");
469
482
  const n = pos.count;
470
483
  const cols = new Float32Array(n * 3);
@@ -662,7 +675,12 @@ function BodyMap3D(props) {
662
675
  position: "fixed",
663
676
  inset: 0,
664
677
  zIndex: 2147483e3,
665
- background: chrome.panelBg,
678
+ // THE STAGE, not the panel. This used to be `chrome.panelBg`,
679
+ // the same field that paints the picker card — so a consumer with
680
+ // a dark canvas got a white void around a dark body in fullscreen,
681
+ // and could not fix it: darkening panelBg hid the 0-10 buttons.
682
+ // One field, two surfaces, opposite needs. (F052.29)
683
+ background: chrome.stageBg,
666
684
  // F052.27 — a fixed inset:0 surface in a standalone/Capacitor
667
685
  // webview covers the STATUS BAR too, so a control laid out at the
668
686
  // top lands on the clock and the battery. Measured by fd-sundhed
@@ -705,7 +723,14 @@ function BodyMap3D(props) {
705
723
  }
706
724
  ) }),
707
725
  /* @__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(
726
+ unsupported ? /* @__PURE__ */ jsx(
727
+ "div",
728
+ {
729
+ "data-testid": "bodymap3d-unsupported",
730
+ 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 },
731
+ children: "3D kr\xE6ver WebGL, som ikke er tilg\xE6ngeligt her."
732
+ }
733
+ ) : /* @__PURE__ */ jsx(
709
734
  "div",
710
735
  {
711
736
  ref: mountRef,
@@ -720,7 +745,7 @@ function BodyMap3D(props) {
720
745
  minHeight: isFullscreen ? 0 : void 0,
721
746
  borderRadius: 16,
722
747
  overflow: "hidden",
723
- background: "#0e1424",
748
+ background: chrome.stageBg,
724
749
  touchAction: "none"
725
750
  }
726
751
  }
@@ -756,6 +781,6 @@ function BodyMap3D(props) {
756
781
  );
757
782
  }
758
783
 
759
- export { BodyMap3D, seamBlend, serializeReport };
784
+ export { BodyMap3D, ensureNormals, seamBlend, seamWeight, serializeReport };
760
785
  //# sourceMappingURL=three.js.map
761
786
  //# sourceMappingURL=three.js.map