@broberg/bodymap 0.1.4 → 0.2.1

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/README.md CHANGED
@@ -4,9 +4,9 @@ Interactive body **pain-map**: a genderless body a patient clicks to mark where
4
4
  hurts, producing a structured `PainReport` (the shared `bodymap/v1` wire format) —
5
5
  never a bare image. The clinical region taxonomy, the data model and the selection
6
6
  engine are **framework-neutral**; the renderer is swappable. This release ships the
7
- headless core + a 2D SVG React renderer (front view). A rotatable 3D renderer
8
- (vanilla Three.js) and a 2D back view land in follow-up `0.1.x` releases on the
9
- same core.
7
+ headless core, a 2D SVG React renderer (front + back view) **and** a rotatable
8
+ 3D renderer (vanilla three.js) all on the same core and the same
9
+ `bodymap/v1` wire.
10
10
 
11
11
  ```bash
12
12
  npm i @broberg/bodymap
@@ -78,6 +78,49 @@ type RegionConfig = Record<string, { visible?: boolean; selectable?: boolean }>;
78
78
  An absent key is visible + selectable. Hidden ⇒ never selectable. `resolveRegions`
79
79
  and `isSelectable` apply the config for custom renderers.
80
80
 
81
+ ## 3D renderer (React + three.js)
82
+
83
+ A rotatable 3D body on the **same** core + wire — a realistic Blender Studio human
84
+ base mesh (CC0), drag to rotate, scroll to zoom, hover to highlight, click a body
85
+ part to mark pain (the part colours by intensity). Vanilla three.js (not
86
+ react-three-fiber) so it runs in React, Preact and a Capacitor webview alike.
87
+
88
+ ```tsx
89
+ import { BodyMap3D } from "@broberg/bodymap/three";
90
+
91
+ <BodyMap3D
92
+ models={{ male: "/models/body-male.glb", female: "/models/body-female.glb" }}
93
+ onChange={(report) => save(report)} // same PainReport (bodymap/v1)
94
+ palette={palette} // same BodymapPalette
95
+ locale="da" // da | en (region names + UI)
96
+ autoRotate={false} // idle when static (battery + Lens-friendly)
97
+ />;
98
+ ```
99
+
100
+ - **`three` is an optional peer** — only this subpath pulls it in (`npm i three`).
101
+ The 2D renderer + core stay three-free.
102
+ - **You host the models.** The package ships reference GLBs at
103
+ `@broberg/bodymap/models/body-male.glb` + `…/body-female.glb` (~512 KB each) —
104
+ copy them to your `public/` (or `import url from "@broberg/bodymap/models/body-male.glb?url"`
105
+ with a bundler that emits asset URLs) and pass the URLs via `models`. Nothing is
106
+ bundled into the JS, and no model is fetched from a third party.
107
+ - **On-demand rendering.** The scene renders only while auto-rotating or while a
108
+ gesture is settling, then goes idle — so it doesn't drain a phone's battery and a
109
+ headless Lens/Playwright run can actually land clicks.
110
+ - **WebGL-safe.** With no WebGL context the component renders a graceful fallback
111
+ instead of crashing.
112
+ - **Same contract as 2D.** `onChange` emits the identical `PainReport`, so swapping
113
+ `<BodyMap>` for `<BodyMap3D>` needs zero change to your report handling. It honours
114
+ the per-app `config` too — a non-selectable region isn't pickable in 3D either.
115
+ - **Body type from a profile.** Pass `sex="male" | "female"` to run it fully
116
+ controlled (the parent owns it), and `showSexToggle={false}` to hide the picker —
117
+ common when the body type comes from the user's profile and a mid-flow toggle would
118
+ just be noise. Omit both to get the built-in toggle (uncontrolled).
119
+ - **No WebXR/VR.** This is a rotatable in-page 3D canvas, not an immersive session —
120
+ WebXR is intentionally out (a Capacitor webview can't host it reliably).
121
+ - Every control carries a `data-testid` (`bodymap3d-canvas`, `bodymap3d-sex-*`,
122
+ `bodymap3d-intensity-<n>`, `bodymap3d-type-<quality>`, `bodymap3d-ready`).
123
+
81
124
  ## Colour control — `BodymapPalette`
82
125
 
83
126
  Both renderers theme off one palette (consumer-defined):
@@ -99,10 +142,9 @@ baseColorFor("chest", palette); // → "#e6e9f2" (per-region override, else
99
142
 
100
143
  ## Roadmap
101
144
 
102
- - 2D **back view** (LUMBAR / THORA / HIP posterior) — `0.1.x`
103
- - Rotatable **3D** renderer (vanilla Three.js, realistic Blender Studio CC0 body,
104
- click-to-colour by pain) — `0.1.x`
105
- - Preact adapter
145
+ - True **per-zone mesh segmentation** for the 3D renderer (v0.2.0 assigns each
146
+ vertex to its nearest region anchor; sharp painted zones come next)
147
+ - **Preact** adapter
106
148
 
107
149
  ## License
108
150
 
package/dist/three.cjs ADDED
@@ -0,0 +1,581 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var THREE = require('three');
5
+ var GLTFLoader_js = require('three/examples/jsm/loaders/GLTFLoader.js');
6
+ var OrbitControls_js = require('three/examples/jsm/controls/OrbitControls.js');
7
+ var zod = require('zod');
8
+ var jsxRuntime = require('react/jsx-runtime');
9
+
10
+ function _interopNamespace(e) {
11
+ if (e && e.__esModule) return e;
12
+ var n = Object.create(null);
13
+ if (e) {
14
+ Object.keys(e).forEach(function (k) {
15
+ if (k !== 'default') {
16
+ var d = Object.getOwnPropertyDescriptor(e, k);
17
+ Object.defineProperty(n, k, d.get ? d : {
18
+ enumerable: true,
19
+ get: function () { return e[k]; }
20
+ });
21
+ }
22
+ });
23
+ }
24
+ n.default = e;
25
+ return Object.freeze(n);
26
+ }
27
+
28
+ var THREE__namespace = /*#__PURE__*/_interopNamespace(THREE);
29
+
30
+ // src/three.tsx
31
+ var REGIONS = [
32
+ // axis / centre-line (serialised side "center")
33
+ { key: "head", label: "Hoved", code: "HEAD" },
34
+ { key: "neck", label: "Nakke", code: "NECK" },
35
+ { key: "chest", label: "Bryst", code: "CHEST" },
36
+ { key: "thora", label: "\xD8vre ryg (thorakal)", code: "THORA" },
37
+ { key: "lumbar", label: "L\xE6nd (lumbal)", code: "LUMBAR" },
38
+ { key: "groin", label: "Lyske", code: "GROIN" },
39
+ // paired limbs / sides (L / R)
40
+ { key: "shoulder_left", label: "Skulder, venstre", code: "SHOULDER", side: "left" },
41
+ { key: "shoulder_right", label: "Skulder, h\xF8jre", code: "SHOULDER", side: "right" },
42
+ { key: "uarm_left", label: "Overarm, venstre", code: "UARM", side: "left" },
43
+ { key: "uarm_right", label: "Overarm, h\xF8jre", code: "UARM", side: "right" },
44
+ { key: "elbow_left", label: "Albue, venstre", code: "ELBOW", side: "left" },
45
+ { key: "elbow_right", label: "Albue, h\xF8jre", code: "ELBOW", side: "right" },
46
+ { key: "farm_left", label: "Underarm, venstre", code: "FARM", side: "left" },
47
+ { key: "farm_right", label: "Underarm, h\xF8jre", code: "FARM", side: "right" },
48
+ { key: "wrist_left", label: "H\xE5ndled, venstre", code: "WRIST", side: "left" },
49
+ { key: "wrist_right", label: "H\xE5ndled, h\xF8jre", code: "WRIST", side: "right" },
50
+ { key: "hand_left", label: "H\xE5nd, venstre", code: "HAND", side: "left" },
51
+ { key: "hand_right", label: "H\xE5nd, h\xF8jre", code: "HAND", side: "right" },
52
+ { key: "hip_left", label: "Hofte, venstre", code: "HIP", side: "left" },
53
+ { key: "hip_right", label: "Hofte, h\xF8jre", code: "HIP", side: "right" },
54
+ { key: "thigh_left", label: "L\xE5r, venstre", code: "THIGH", side: "left" },
55
+ { key: "thigh_right", label: "L\xE5r, h\xF8jre", code: "THIGH", side: "right" },
56
+ { key: "knee_left", label: "Kn\xE6, venstre", code: "KNEE", side: "left" },
57
+ { key: "knee_right", label: "Kn\xE6, h\xF8jre", code: "KNEE", side: "right" },
58
+ { key: "lowleg_left", label: "Underben, venstre", code: "LOWLEG", side: "left" },
59
+ { key: "lowleg_right", label: "Underben, h\xF8jre", code: "LOWLEG", side: "right" },
60
+ { key: "ankle_left", label: "Ankel, venstre", code: "ANKLE", side: "left" },
61
+ { key: "ankle_right", label: "Ankel, h\xF8jre", code: "ANKLE", side: "right" },
62
+ { key: "foot_left", label: "Fod, venstre", code: "FOOT", side: "left" },
63
+ { key: "foot_right", label: "Fod, h\xF8jre", code: "FOOT", side: "right" }
64
+ ];
65
+ var REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));
66
+ REGIONS.map((r) => r.key);
67
+ function getRegion(key) {
68
+ return REGIONS.find((r) => r.key === key);
69
+ }
70
+ var PAIN_TYPES = ["stikkende", "dump", "konstant", "jagende"];
71
+ var painPointSchema = zod.z.object({
72
+ region: zod.z.string().refine((k) => REGION_KEY_SET.has(k), { message: "unknown region" }),
73
+ intensity: zod.z.number().int().min(0).max(10),
74
+ type: zod.z.enum(PAIN_TYPES).optional(),
75
+ timestamp: zod.z.string()
76
+ });
77
+ zod.z.array(painPointSchema);
78
+ function isSelectable(key, config = {}) {
79
+ const s = config[key];
80
+ if (s?.visible === false) return false;
81
+ return s?.selectable ?? true;
82
+ }
83
+ var defaultPalette = {
84
+ body: "#d2d7de",
85
+ hover: "#8fd0cd",
86
+ selected: "#5cc4b7",
87
+ heat: { low: "#fcd34d", mid: "#fb923c", high: "#ef4444" }
88
+ };
89
+ function heatFor(intensity, palette = defaultPalette) {
90
+ return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;
91
+ }
92
+ function baseColorFor(regionKey, palette = defaultPalette) {
93
+ return palette.regions?.[regionKey] ?? palette.body;
94
+ }
95
+ zod.z.object({
96
+ schema: zod.z.literal("bodymap/v1"),
97
+ view: zod.z.enum(["front", "back", "left", "right"]),
98
+ points: zod.z.array(
99
+ zod.z.object({
100
+ region: zod.z.string(),
101
+ side: zod.z.enum(["left", "right", "center"]),
102
+ intensity: zod.z.number().int().min(0).max(10),
103
+ quality: zod.z.enum(PAIN_TYPES).optional()
104
+ })
105
+ )
106
+ });
107
+ function serializeReport(report, opts = {}) {
108
+ return {
109
+ schema: "bodymap/v1",
110
+ view: opts.view ?? "front",
111
+ points: report.map((p) => {
112
+ const r = getRegion(p.region);
113
+ return {
114
+ region: r?.code ?? p.region,
115
+ side: r?.side ?? "center",
116
+ intensity: p.intensity,
117
+ quality: p.type
118
+ };
119
+ })
120
+ };
121
+ }
122
+ var daRegions = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));
123
+ var EN_CODE = {
124
+ HEAD: "Head",
125
+ NECK: "Neck",
126
+ CHEST: "Chest",
127
+ THORA: "Upper back",
128
+ LUMBAR: "Lower back",
129
+ GROIN: "Groin",
130
+ SHOULDER: "Shoulder",
131
+ UARM: "Upper arm",
132
+ ELBOW: "Elbow",
133
+ FARM: "Forearm",
134
+ WRIST: "Wrist",
135
+ HAND: "Hand",
136
+ HIP: "Hip",
137
+ THIGH: "Thigh",
138
+ KNEE: "Knee",
139
+ LOWLEG: "Lower leg",
140
+ ANKLE: "Ankle",
141
+ FOOT: "Foot"
142
+ };
143
+ var enRegions = Object.fromEntries(
144
+ REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? r.side === "left" ? ", left" : ", right" : "")])
145
+ );
146
+ var LABELS_DA = {
147
+ regions: daRegions,
148
+ qualities: { stikkende: "stikkende", dump: "dump", konstant: "konstant", jagende: "jagende" },
149
+ intensity: "Intensitet (0-10)",
150
+ quality: "Kvalitet",
151
+ remove: "Fjern punkt",
152
+ front: "Forfra",
153
+ back: "Bagfra",
154
+ empty: "V\xE6lg en kropsdel for at markere smerte.",
155
+ before: "F\xF8r",
156
+ after: "Efter",
157
+ noChange: "Ingen \xE6ndring",
158
+ change: { new: "nyt", resolved: "forsvundet", improved: "bedre", worse: "v\xE6rre" },
159
+ ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? ", " + q : ""}`,
160
+ ariaUnmarked: (n) => `${n}, ikke markeret. Aktiv\xE9r for at markere smerte.`,
161
+ svgLabel: "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
162
+ viewLabel: "Visning",
163
+ zoomLabel: "Zoom",
164
+ zoomIn: "Zoom ind",
165
+ zoomOut: "Zoom ud",
166
+ zoomReset: "Nulstil zoom"
167
+ };
168
+ var LABELS_EN = {
169
+ regions: enRegions,
170
+ qualities: { stikkende: "stabbing", dump: "dull", konstant: "constant", jagende: "shooting" },
171
+ intensity: "Intensity (0-10)",
172
+ quality: "Quality",
173
+ remove: "Remove point",
174
+ front: "Front",
175
+ back: "Back",
176
+ empty: "Pick a body part to mark pain.",
177
+ before: "Before",
178
+ after: "After",
179
+ noChange: "No change",
180
+ change: { new: "new", resolved: "resolved", improved: "improved", worse: "worse" },
181
+ ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? ", " + q : ""}`,
182
+ ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,
183
+ svgLabel: "Body map \u2014 pick where it hurts",
184
+ viewLabel: "View",
185
+ zoomLabel: "Zoom",
186
+ zoomIn: "Zoom in",
187
+ zoomOut: "Zoom out",
188
+ zoomReset: "Reset zoom"
189
+ };
190
+ var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Hover for at fremh\xE6ve \xB7 klik en kropsdel for at markere smerte." };
191
+ var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain." };
192
+ var ANCHORS = {
193
+ head: [0, 1.79, 0.02],
194
+ neck: [0, 1.57, 0],
195
+ chest: [0, 1.42, 0.11],
196
+ thora: [0, 1.42, -0.12],
197
+ lumbar: [0, 1.13, -0.13],
198
+ groin: [0, 0.92, 0.09],
199
+ shoulder_left: [-0.2, 1.5, 0],
200
+ shoulder_right: [0.2, 1.5, 0],
201
+ uarm_left: [-0.27, 1.3, 0],
202
+ uarm_right: [0.27, 1.3, 0],
203
+ elbow_left: [-0.31, 1.08, 0],
204
+ elbow_right: [0.31, 1.08, 0],
205
+ farm_left: [-0.34, 0.93, 0.02],
206
+ farm_right: [0.34, 0.93, 0.02],
207
+ wrist_left: [-0.36, 0.79, 0.02],
208
+ wrist_right: [0.36, 0.79, 0.02],
209
+ hand_left: [-0.37, 0.68, 0.03],
210
+ hand_right: [0.37, 0.68, 0.03],
211
+ hip_left: [-0.14, 1.02, -0.03],
212
+ hip_right: [0.14, 1.02, -0.03],
213
+ thigh_left: [-0.1, 0.68, 0.05],
214
+ thigh_right: [0.1, 0.68, 0.05],
215
+ knee_left: [-0.1, 0.4, 0.06],
216
+ knee_right: [0.1, 0.4, 0.06],
217
+ lowleg_left: [-0.1, 0.22, 0.04],
218
+ lowleg_right: [0.1, 0.22, 0.04],
219
+ ankle_left: [-0.1, 0.05, 0.02],
220
+ ankle_right: [0.1, 0.05, 0.02],
221
+ foot_left: [-0.1, 0.02, 0.11],
222
+ foot_right: [0.1, 0.02, 0.11]
223
+ };
224
+ var ANCHOR_KEYS = Object.keys(ANCHORS);
225
+ for (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];
226
+ function mergeLabels(locale, overrides) {
227
+ const base = locale === "en" ? LABELS_EN : LABELS_DA;
228
+ if (!overrides) return base;
229
+ return {
230
+ ...base,
231
+ ...overrides,
232
+ regions: { ...base.regions, ...overrides.regions },
233
+ qualities: { ...base.qualities, ...overrides.qualities }
234
+ };
235
+ }
236
+ function webglAvailable() {
237
+ try {
238
+ const c = document.createElement("canvas");
239
+ return !!(c.getContext("webgl") || c.getContext("experimental-webgl"));
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+ var btn = { font: "inherit", cursor: "pointer", borderRadius: 8, border: "1px solid #e2e8f0", background: "#fff", padding: "6px 9px" };
245
+ var seg = (on) => ({ ...btn, background: on ? "#0e8f8a" : "#fff", color: on ? "#fff" : "#1e293b", fontWeight: 600 });
246
+ function BodyMap3D(props) {
247
+ const {
248
+ models,
249
+ value,
250
+ defaultValue,
251
+ onChange,
252
+ config,
253
+ palette = defaultPalette,
254
+ locale = "da",
255
+ labels,
256
+ ui,
257
+ defaultSex = "male",
258
+ sex: sexProp,
259
+ showSexToggle = true,
260
+ onSexChange,
261
+ autoRotate = true,
262
+ className
263
+ } = props;
264
+ const L = mergeLabels(locale, labels);
265
+ const UI = { ...locale === "en" ? UI_EN : UI_DA, ...ui };
266
+ const nameOf = (key) => L.regions[key] ?? getRegion(key)?.label ?? key;
267
+ const mountRef = react.useRef(null);
268
+ const loadedRef = react.useRef(null);
269
+ const [unsupported, setUnsupported] = react.useState(false);
270
+ const [ready, setReady] = react.useState(false);
271
+ const [internalSex, setInternalSex] = react.useState(defaultSex);
272
+ const sex = sexProp ?? internalSex;
273
+ const [selected, setSelected] = react.useState(null);
274
+ const [internal, setInternal] = react.useState(defaultValue ?? []);
275
+ const report = value ?? internal;
276
+ const commit = (next) => {
277
+ if (value === void 0) setInternal(next);
278
+ onChange?.(next);
279
+ };
280
+ const reportRef = react.useRef(report);
281
+ reportRef.current = report;
282
+ const selectedRef = react.useRef(selected);
283
+ selectedRef.current = selected;
284
+ const paletteRef = react.useRef(palette);
285
+ paletteRef.current = palette;
286
+ const configRef = react.useRef(config);
287
+ configRef.current = config;
288
+ const modelsRef = react.useRef(models);
289
+ modelsRef.current = models;
290
+ const sexRef = react.useRef(sex);
291
+ sexRef.current = sex;
292
+ const setSelectedRef = react.useRef(setSelected);
293
+ setSelectedRef.current = setSelected;
294
+ const setReadyRef = react.useRef(setReady);
295
+ setReadyRef.current = setReady;
296
+ const apiRef = react.useRef(null);
297
+ react.useEffect(() => {
298
+ if (!webglAvailable()) {
299
+ setUnsupported(true);
300
+ return;
301
+ }
302
+ const el = mountRef.current;
303
+ if (!el) return;
304
+ let W = el.clientWidth || 520, H = el.clientHeight || 600;
305
+ const scene = new THREE__namespace.Scene();
306
+ scene.background = new THREE__namespace.Color(922660);
307
+ const camera = new THREE__namespace.PerspectiveCamera(32, W / H, 0.1, 100);
308
+ camera.position.set(0, 1.05, 4.4);
309
+ let renderer;
310
+ try {
311
+ renderer = new THREE__namespace.WebGLRenderer({ antialias: true });
312
+ } catch {
313
+ setUnsupported(true);
314
+ return;
315
+ }
316
+ renderer.setSize(W, H);
317
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
318
+ el.appendChild(renderer.domElement);
319
+ scene.add(new THREE__namespace.HemisphereLight(16777215, 2240580, 1.05));
320
+ const kl = new THREE__namespace.DirectionalLight(16777215, 1.5);
321
+ kl.position.set(3, 5, 4);
322
+ scene.add(kl);
323
+ const rl = new THREE__namespace.DirectionalLight(8956671, 0.7);
324
+ rl.position.set(-4, 2, -3);
325
+ scene.add(rl);
326
+ const controls = new OrbitControls_js.OrbitControls(camera, renderer.domElement);
327
+ controls.enableDamping = true;
328
+ controls.dampingFactor = 0.08;
329
+ controls.autoRotate = autoRotate;
330
+ controls.autoRotateSpeed = 1.1;
331
+ controls.minDistance = 2.2;
332
+ controls.maxDistance = 8;
333
+ controls.enablePan = false;
334
+ controls.target.set(0, 0.95, 0);
335
+ controls.addEventListener("start", () => {
336
+ controls.autoRotate = false;
337
+ });
338
+ let raf = 0;
339
+ let pumping = false;
340
+ const renderFrame = () => renderer.render(scene, camera);
341
+ const pump = () => {
342
+ const moved = controls.update();
343
+ renderFrame();
344
+ if (moved || controls.autoRotate) {
345
+ raf = requestAnimationFrame(pump);
346
+ } else {
347
+ pumping = false;
348
+ raf = 0;
349
+ }
350
+ };
351
+ const kick = () => {
352
+ if (!pumping && !document.hidden) {
353
+ pumping = true;
354
+ raf = requestAnimationFrame(pump);
355
+ }
356
+ };
357
+ controls.addEventListener("change", kick);
358
+ let modelRoot = null;
359
+ let bodyMesh = null;
360
+ let vertexRegion = [];
361
+ let colorAttr = null;
362
+ let hovered = null;
363
+ const loader = new GLTFLoader_js.GLTFLoader();
364
+ const anchorVecs = ANCHOR_KEYS.map((k) => new THREE__namespace.Vector3(...ANCHORS[k]));
365
+ const tmp = new THREE__namespace.Color();
366
+ const restingHex = (key) => {
367
+ const pt = reportRef.current.find((p) => p.region === key);
368
+ if (pt) return heatFor(pt.intensity, paletteRef.current);
369
+ if (selectedRef.current === key) return paletteRef.current.selected;
370
+ return baseColorFor(key, paletteRef.current);
371
+ };
372
+ const colorRegion = (key, hex) => {
373
+ if (!colorAttr) return;
374
+ tmp.set(hex);
375
+ for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);
376
+ colorAttr.needsUpdate = true;
377
+ };
378
+ const refresh = () => {
379
+ for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k));
380
+ renderFrame();
381
+ };
382
+ const loadModel = (which) => {
383
+ const url = which === "female" ? modelsRef.current.female : modelsRef.current.male;
384
+ loader.load(url, (gltf) => {
385
+ if (modelRoot) scene.remove(modelRoot);
386
+ const model = gltf.scene;
387
+ const box = new THREE__namespace.Box3().setFromObject(model);
388
+ const size = new THREE__namespace.Vector3();
389
+ box.getSize(size);
390
+ const center = new THREE__namespace.Vector3();
391
+ box.getCenter(center);
392
+ const scale = 1.9 / size.y;
393
+ model.scale.setScalar(scale);
394
+ model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);
395
+ model.updateMatrixWorld(true);
396
+ bodyMesh = null;
397
+ model.traverse((o) => {
398
+ const m = o;
399
+ if (m.isMesh && !bodyMesh) bodyMesh = m;
400
+ });
401
+ if (bodyMesh) {
402
+ const geo = bodyMesh.geometry;
403
+ geo.computeVertexNormals();
404
+ const pos = geo.getAttribute("position");
405
+ const n = pos.count;
406
+ const cols = new Float32Array(n * 3);
407
+ vertexRegion = new Array(n);
408
+ const v = new THREE__namespace.Vector3();
409
+ for (let i = 0; i < n; i++) {
410
+ v.fromBufferAttribute(pos, i).applyMatrix4(bodyMesh.matrixWorld);
411
+ let best = 0, bd = Infinity;
412
+ for (let a = 0; a < anchorVecs.length; a++) {
413
+ const d = v.distanceToSquared(anchorVecs[a]);
414
+ if (d < bd) {
415
+ bd = d;
416
+ best = a;
417
+ }
418
+ }
419
+ vertexRegion[i] = ANCHOR_KEYS[best];
420
+ }
421
+ colorAttr = new THREE__namespace.BufferAttribute(cols, 3);
422
+ geo.setAttribute("color", colorAttr);
423
+ bodyMesh.material = new THREE__namespace.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04 });
424
+ refresh();
425
+ }
426
+ modelRoot = model;
427
+ scene.add(model);
428
+ renderFrame();
429
+ kick();
430
+ setReadyRef.current(true);
431
+ if (loadedRef.current) {
432
+ loadedRef.current.setAttribute("data-loaded", "true");
433
+ loadedRef.current.setAttribute("data-model", which);
434
+ }
435
+ });
436
+ };
437
+ loadModel(sexRef.current);
438
+ apiRef.current = {
439
+ setSex: (s) => {
440
+ loadedRef.current?.removeAttribute("data-loaded");
441
+ setReadyRef.current(false);
442
+ loadModel(s);
443
+ },
444
+ refresh
445
+ };
446
+ const raycaster = new THREE__namespace.Raycaster();
447
+ const ndc = new THREE__namespace.Vector2();
448
+ const pick = (clientX, clientY) => {
449
+ if (!bodyMesh) return null;
450
+ const rect = renderer.domElement.getBoundingClientRect();
451
+ ndc.set((clientX - rect.left) / rect.width * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
452
+ raycaster.setFromCamera(ndc, camera);
453
+ const hits = raycaster.intersectObject(bodyMesh, true);
454
+ if (!hits.length) return null;
455
+ const p = hits[0].point;
456
+ let best = null, bd = Infinity;
457
+ for (const k of ANCHOR_KEYS) {
458
+ if (!isSelectable(k, configRef.current ?? {})) continue;
459
+ const a = ANCHORS[k];
460
+ const d = (p.x - a[0]) ** 2 + (p.y - a[1]) ** 2 + (p.z - a[2]) ** 2;
461
+ if (d < bd) {
462
+ bd = d;
463
+ best = k;
464
+ }
465
+ }
466
+ return best;
467
+ };
468
+ let downX = 0, downY = 0, downT = 0;
469
+ const canvas = renderer.domElement;
470
+ const onDown = (e) => {
471
+ downX = e.clientX;
472
+ downY = e.clientY;
473
+ downT = Date.now();
474
+ };
475
+ const onMove = (e) => {
476
+ if ((e.buttons || 0) !== 0) return;
477
+ const k = pick(e.clientX, e.clientY);
478
+ if (k === hovered) return;
479
+ const prev = hovered;
480
+ hovered = k;
481
+ if (prev) colorRegion(prev, restingHex(prev));
482
+ if (k) colorRegion(k, paletteRef.current.hover);
483
+ canvas.style.cursor = k ? "pointer" : "default";
484
+ renderFrame();
485
+ };
486
+ const onUp = (e) => {
487
+ if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || Date.now() - downT > 450) return;
488
+ const k = pick(e.clientX, e.clientY);
489
+ if (k) setSelectedRef.current(k);
490
+ };
491
+ canvas.addEventListener("pointerdown", onDown);
492
+ canvas.addEventListener("pointermove", onMove);
493
+ canvas.addEventListener("pointerup", onUp);
494
+ renderFrame();
495
+ kick();
496
+ const onVis = () => {
497
+ if (document.hidden) {
498
+ cancelAnimationFrame(raf);
499
+ pumping = false;
500
+ raf = 0;
501
+ } else kick();
502
+ };
503
+ document.addEventListener("visibilitychange", onVis);
504
+ const onResize = () => {
505
+ W = el.clientWidth || 520;
506
+ H = el.clientHeight || 600;
507
+ camera.aspect = W / H;
508
+ camera.updateProjectionMatrix();
509
+ renderer.setSize(W, H);
510
+ renderFrame();
511
+ };
512
+ window.addEventListener("resize", onResize);
513
+ return () => {
514
+ cancelAnimationFrame(raf);
515
+ window.removeEventListener("resize", onResize);
516
+ document.removeEventListener("visibilitychange", onVis);
517
+ controls.removeEventListener("change", kick);
518
+ canvas.removeEventListener("pointerdown", onDown);
519
+ canvas.removeEventListener("pointermove", onMove);
520
+ canvas.removeEventListener("pointerup", onUp);
521
+ controls.dispose();
522
+ renderer.dispose();
523
+ if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
524
+ };
525
+ }, []);
526
+ react.useEffect(() => {
527
+ apiRef.current?.refresh();
528
+ }, [selected, report, palette]);
529
+ const sexInited = react.useRef(false);
530
+ react.useEffect(() => {
531
+ if (!sexInited.current) {
532
+ sexInited.current = true;
533
+ return;
534
+ }
535
+ apiRef.current?.setSex(sex);
536
+ }, [sex]);
537
+ const pointOf = (k) => report.find((p) => p.region === k);
538
+ const setPain = (k, intensity, type) => {
539
+ commit([...report.filter((p) => p.region !== k), { region: k, intensity, type, timestamp: (/* @__PURE__ */ new Date()).toISOString() }]);
540
+ };
541
+ const removePain = (k) => {
542
+ commit(report.filter((p) => p.region !== k));
543
+ setSelected(null);
544
+ };
545
+ const changeSex = (s) => {
546
+ if (sexProp === void 0) setInternalSex(s);
547
+ onSexChange?.(s);
548
+ };
549
+ const region = selected ? REGIONS.find((r) => r.key === selected) : null;
550
+ const current = selected ? pointOf(selected) : void 0;
551
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-testid": "bodymap3d-root", className, style: { fontFamily: "system-ui, sans-serif", color: "#1e293b" }, children: [
552
+ showSexToggle && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
553
+ /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
554
+ /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
555
+ ] }) }),
556
+ /* @__PURE__ */ jsxRuntime.jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
557
+ ready && /* @__PURE__ */ jsxRuntime.jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
558
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [
559
+ unsupported ? /* @__PURE__ */ jsxRuntime.jsx("div", { "data-testid": "bodymap3d-unsupported", style: { flex: "1 1 520px", minWidth: 320, height: "60vh", 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__ */ jsxRuntime.jsx("div", { ref: mountRef, "data-testid": "bodymap3d-canvas", style: { flex: "1 1 520px", height: "60vh", minWidth: 320, borderRadius: 16, overflow: "hidden", background: "#0e1424", touchAction: "none" } }),
560
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { flex: "1 1 300px", minWidth: 260 }, children: region ? /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { border: "1px solid #e2e8f0", borderRadius: 14, padding: 16, background: "#fff" }, children: [
561
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
562
+ /* @__PURE__ */ jsxRuntime.jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
563
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { font: "11px ui-monospace, monospace", color: "#64748b", background: "#f1f5f9", borderRadius: 6, padding: "2px 7px" }, children: [
564
+ region.code,
565
+ region.side ? " \xB7 " + region.side : ""
566
+ ] })
567
+ ] }),
568
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "#94a3b8", marginBottom: 7 }, children: L.intensity }),
569
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn, width: 30, height: 30, background: current?.intensity === i ? "#0e8f8a" : "#fff", color: current?.intensity === i ? "#fff" : "#1e293b" }, children: i }, i)) }),
570
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "#94a3b8", marginBottom: 7 }, children: L.quality }),
571
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 }, children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsxRuntime.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)) }),
572
+ current && /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: "#ef4444", borderColor: "#f6c9c9" }, children: L.remove })
573
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { "data-testid": "bodymap3d-empty", style: { border: "1px solid #e2e8f0", borderRadius: 14, padding: 16, background: "#fff", color: "#94a3b8", fontSize: 13.5 }, children: UI.hoverHint }) })
574
+ ] })
575
+ ] });
576
+ }
577
+
578
+ exports.BodyMap3D = BodyMap3D;
579
+ exports.serializeReport = serializeReport;
580
+ //# sourceMappingURL=three.cjs.map
581
+ //# sourceMappingURL=three.cjs.map