PageSourceSearch

https://caldariprimeponyclub.com/src/ui/PlacementControl.mjs

js caldariprimeponyclub.com collected 2026-10-07 19:37:41 UTC 17,938 bytes, 707 lines download raw bytes

1import { StageControl } from "./StageControl.mjs";
2import { ForgetPlacements, HullFamily, HullKey, PlacementsFor } from "../render/Placements.mjs";
3import { AddGooglyEyes, GOOGLY_EYE_SET, RemoveGooglyEyes } from "../render/GooglyEyes.mjs";
4import { ScenePicker } from "../render/Picking.mjs";
5import { DescribeShortcut, Shortcuts } from "./Shortcuts.mjs";
6
7const DEFAULT_RADIUS = 2;
8
9const DEFAULT_GRAVITY = 0.981;
10
11const FOCUS_DISTANCE = 10;
12
13const NEW_EYE_OFFSET = 5;
14
15let asking = null;
16
17export function ForgetEditor()
18{
19    asking = null;
20}
21
22function Ask()
23{
24    asking ??= fetch("/api/placements", { credentials: "same-origin" })
25        .then(response => response.ok ? response.json() : { editor: false, hulls: [] })
26        .catch(() => ({ editor: false, hulls: [] }));
27
28    return asking;
29}
30
31export async function CanEditPlacements()
32{
33    return Boolean((await Ask())?.editor);
34}
35
36export async function Hulls()
37{
38    const answer = await Ask();
39
40    return Array.isArray(answer?.hulls) ? answer.hulls : [];
41}
42
43export class PlacementControl extends StageControl
44{
45
46    #section;
47
48    #edit;
49
50    #eye;
51
52    #mode;
53
54    #size;
55
56    #gravity;
57
58    #scope;
59
60    #copy;
61
62    #status;
63
64    #gizmo = null;
65
66    #picker;
67
68    #picking = null;
69
70    #dirty = false;
71
72    constructor(options)
73    {
74        super(options);
75
76        this.#section = this.#Section();
77
78        this.#picker = new ScenePicker({ renderer: () => this.Renderer() });
79
80        this.#edit = this.Row(this.#section, "EDIT", this.Checkbox());
81        this.#scope = this.Row(this.#section, "HULL", this.#Select());
82        this.#copy = this.Row(this.#section, "COPY FROM", this.#Select());
83        this.#eye = this.Row(this.#section, "EYE", this.#Select());
84        this.#mode = this.Row(this.#section, "HANDLE", this.#Select([
85            [ "translation", "MOVE (W)" ],
86            [ "rotation", "TURN (E)" ],
87            [ "scaling", "SIZE (R)" ]
88        ]));
89        this.#size = this.Row(this.#section, "SIZE", this.Range({ min: 0.5, max: 200, step: 0.5, value: DEFAULT_RADIUS }));
90
91        this.#gravity = this.Row(this.#section, "GRAVITY", this.Range({ min: 0, max: 4, step: 0.05, value: DEFAULT_GRAVITY }));
92
93        this.Row(this.#section, "EYES", this.#Buttons([
94            [ "ADD", () => this.Add() ],
95            [ "MIRROR", () => this.Mirror() ],
96            [ "DROP", () => this.Drop() ]
97        ]));
98
99        this.Row(this.#section, "", this.#Buttons([
100            [ "SAVE", () => this.Save() ],
101            [ "REVERT", () => this.Revert() ]
102        ]));
103
104        this.#status = document.createElement("div");
105
106        this.#status.className = "placement-status";
107        this.#section.appendChild(this.#status);
108
109        this.#edit.addEventListener("change", () => this.#SetEditing(this.#edit.checked));
110        this.#copy.addEventListener("change", () =>
111        {
112            const from = this.#copy.value;
113
114            this.#copy.value = "";
115            this.Copy(from);
116        });
117        this.#eye.addEventListener("change", () => this.#Aim());
118        this.#mode.addEventListener("change", () => this.#gizmo?.SetMode(this.#mode.value));
119        this.#size.addEventListener("input", () => this.#Resize());
120        this.#gravity.addEventListener("input", () => this.#Resize());
121
122        this.OnShip(() =>
123        {
124            if (this.#edit.checked) this.#SetEditing(false);
125            this.#Scopes();
126            this.#Report();
127        });
128
129    }
130
131    #Section()
132    {
133        const section = document.createElement("div");
134        const caption = document.createElement("span");
135
136        section.className = "stage-tools-section";
137        section.dataset.section = "placement";
138        caption.className = "stage-tools-caption";
139        caption.textContent = "PLACEMENT";
140        section.appendChild(caption);
141
142        section.hidden = true;
143
144        this.tools?.controls?.prepend(section);
145
146        return section;
147    }
148
149    #Select(options = [])
150    {
151        const select = document.createElement("select");
152
153        for (const [ value, label ] of options)
154        {
155            const option = document.createElement("option");
156
157            option.value = value;
158            option.textContent = label;
159            select.appendChild(option);
160        }
161
162        return select;
163    }
164
165    #Buttons(entries)
166    {
167
168        const row = document.createElement("div");
169
170        row.className = "placement-buttons";
171
172        for (const [ label, handler ] of entries)
173        {
174            const button = document.createElement("button");
175
176            button.type = "button";
177            button.className = "placement-button";
178            button.textContent = label;
179            button.addEventListener("click", handler);
180            row.appendChild(button);
181        }
182
183        return row;
184    }
185
186    #Scopes()
187    {
188        const key = HullKey(this.#Dna());
189
190        this.#scope.replaceChildren();
191
192        const option = document.createElement("option");
193
194        option.value = key;
195        option.textContent = key || "-";
196        this.#scope.appendChild(option);
197        this.#scope.value = key;
198
199        this.#Relatives(key);
200    }
201
202    async #Relatives(key)
203    {
204        const family = HullFamily(key);
205        const listed = await Hulls();
206        const others = listed
207            .filter(row => row.hull !== key && row.set_name === GOOGLY_EYE_SET)
208            .sort((a, b) =>
209                Number(HullFamily(b.hull) === family) - Number(HullFamily(a.hull) === family)
210                || a.hull.localeCompare(b.hull));
211
212        this.#copy.replaceChildren();
213
214        const none = document.createElement("option");
215
216        none.value = "";
217        none.textContent = others.length ? "-" : "nothing to copy";
218        this.#copy.appendChild(none);
219
220        for (const row of others)
221        {
222            const option = document.createElement("option");
223
224            option.value = row.hull;
225            option.textContent = HullFamily(row.hull) === family ? `${row.hull} (family)` : row.hull;
226            this.#copy.appendChild(option);
227        }
228    }
229
230    async Copy(hull)
231    {
232        if (!hull || !this.#edit.checked) return false;
233
234        const sets = await PlacementsFor(hull);
235        const set = sets.find(entry => entry.name === GOOGLY_EYE_SET);
236
237        if (!set?.locators?.length)
238        {
239            this.#Report(`${hull} has nothing to copy`);
240
241            return false;
242        }
243
244        const radius = Number(set.settings?.eyeRadius);
245        const gravity = Number(set.settings?.gravity);
246
247        if (radius > 0) this.#size.value = String(radius);
248
249        if (Number.isFinite(gravity)) this.#gravity.value = String(gravity);
250
251        this.#Touch();
252        this.#Rebuild(set.locators.map(locator => ({ ...locator })));
253        this.#Report(`copied ${set.locators.length} from ${hull} - unsaved`);
254
255        return true;
256    }
257
258    async Offer()
259    {
260        const allowed = await CanEditPlacements();
261
262        this.#section.hidden = !allowed;
263
264        if (allowed)
265        {
266
267            this.#Keys();
268            this.#Scopes();
269            this.#Report();
270        }
271
272        return allowed;
273    }
274
275    #Dna()
276    {
277        return this.Ship()?.dna ?? "";
278    }
279
280    #Eyes()
281    {
282        const ship = this.Ship();
283
284        return ship?.effectChildren?.find(child => child?.__googlyEyes)?.__googlyEyes ?? null;
285    }
286
287    #Locators()
288    {
289        return (this.#Eyes()?.eyes ?? []).map(eye => ({
290            position: [ ...eye.container.translation ],
291            rotation: [ ...eye.container.rotation ],
292            scaling: [ ...eye.container.scaling ],
293            boneIndex: -1
294        }));
295    }
296
297    #Radius()
298    {
299        return Number(this.#size.value) || DEFAULT_RADIUS;
300    }
301
302    #Gravity()
303    {
304        const value = Number(this.#gravity.value);
305
306        return Number.isFinite(value) ? value : DEFAULT_GRAVITY;
307    }
308
309    #LoadSize()
310    {
311        const settings = this.#Set()?.__settings ?? {};
312        const radius = Number(settings.eyeRadius);
313        const gravity = Number(settings.gravity);
314
315        if (radius > 0) this.#size.value = String(radius);
316
317        if (Number.isFinite(gravity)) this.#gravity.value = String(gravity);
318    }
319
320    #Rebuild(locators)
321    {
322        const ship = this.Ship();
323
324        if (!ship) return false;
325
326        RemoveGooglyEyes(ship);
327
328        if (locators.length) AddGooglyEyes(ship, { locators, eyeRadius: this.#Radius(), gravity: this.#Gravity() });
329
330        this.#Eyelist(locators.length);
331        this.#Aim();
332        this.#Report();
333
334        return true;
335    }
336
337    #Eyelist(count)
338    {
339        const chosen = Number(this.#eye.value);
340
341        this.#eye.replaceChildren();
342
343        for (let index = 0; index < count; index++)
344        {
345            const option = document.createElement("option");
346
347            option.value = String(index);
348            option.textContent = `EYE ${index + 1}`;
349            this.#eye.appendChild(option);
350        }
351
352        if (chosen >= 0 && chosen < count) this.#eye.value = String(chosen);
353    }
354
355    #Gizmo()
356    {
357        if (this.#gizmo) return this.#gizmo;
358
359        const client = this.Renderer()?.client ?? null;
360
361        if (!client?.Create) return null;
362
363        try
364        {
365            const gizmo = client.Create("TnyTransformGizmo", {
366                name: "placementGizmo",
367
368                pickable: false,
369                autoSize: true,
370
371                minAutoSize: 4,
372                maxAutoSize: 120,
373
374                enableDepth: false
375            });
376
377            gizmo.GetTargetBoundingSphereRadius = () => this.#Radius() * 2;
378
379            gizmo.Install(client);
380            gizmo.EnableControls(client);
381            this.#gizmo = gizmo;
382
383            tw2.OnEvent("end_frame", () => this.#Round());
384        }
385        catch (err)
386        {
387
388            this.#gizmo = null;
389            this.#Report(`no gizmo: ${err.message}`);
390            console.error("PlacementControl could not build a gizmo", err);
391        }
392
393        return this.#gizmo;
394    }
395
396    #Keys()
397    {
398        this.shortcuts = new Shortcuts({
399            when: () => this.#edit.checked && Boolean(this.#gizmo),
400            group: "Placement editor"
401        });
402
403        const handles = {
404            w: "translation",
405            t: "translation",
406            e: "rotation",
407            r: "scaling"
408        };
409
410        const shown = { w: "W", t: "T", e: "E", r: "R" };
411
412        const says = { translation: "Move gizmo", rotation: "Rotation gizmo", scaling: "Scale gizmo" };
413
414        for (const [ key, mode ] of Object.entries(handles))
415        {
416            this.shortcuts.On(key, () =>
417            {
418                this.#mode.value = mode;
419                this.#gizmo.SetMode(mode);
420
421            }, key === "t" ? null : says[mode], shown[key]);
422        }
423
424        this.shortcuts.On("f", () => this.Focus(), "Focus on selected");
425        this.shortcuts.On("escape", () => this.#SetEditing(false), "Stop editing", "Esc");
426        DescribeShortcut("Placement editor", "Click", "Select");
427    }
428
429    Focus()
430    {
431        const eyes = this.#Eyes()?.eyes ?? [];
432        const eye = eyes[Number(this.#eye.value)] ?? eyes[0] ?? null;
433        const world = eye?.container?._worldTransform;
434        const renderer = this.Renderer();
435        const pose = renderer?.ReadCameraPose?.();
436
437        if (!world || world.length < 16 || !pose) return false;
438
439        const radius = (eye._eyeRadius ?? this.#Radius()) * (Number(eye.container.scaling?.[0]) || 1);
440
441        return Boolean(renderer.TransitionCamera?.({
442            rotationX: pose.rotationX,
443            rotationY: pose.rotationY,
444
445            poi: [ world[12], world[13], world[14] ],
446
447            distance: radius * FOCUS_DISTANCE
448        }));
449    }
450
451    #Round()
452    {
453        if (!this.#edit.checked || this.#mode.value !== "scaling") return;
454
455        const eyes = this.#Eyes()?.eyes ?? [];
456        const eye = eyes[Number(this.#eye.value)] ?? null;
457        const scaling = eye?.container?.scaling;
458
459        if (!scaling) return;
460
461        const size = Math.max(scaling[0], scaling[1], scaling[2]);
462
463        scaling[0] = scaling[1] = scaling[2] = size;
464    }
465
466    #Candidates()
467    {
468        return (this.#Eyes()?.eyes ?? []).map((eye, index) => ({
469            index,
470            object: eye.container,
471            transform: eye.container._worldTransform,
472            radius: (eye._eyeRadius ?? this.#Radius()) * (Number(eye.container.scaling?.[0]) || 1)
473        }));
474    }
475
476    #Click(event)
477    {
478        if (!this.#edit.checked) return;
479        if (event.button !== undefined && event.button !== 0) return;
480
481        const picked = this.#picker.Pick(event, this.#Candidates());
482
483        if (!picked) return;
484
485        this.#eye.value = String(picked.index);
486        this.#Aim();
487        this.#Report();
488    }
489
490    #SetPicking(on)
491    {
492        const element = this.#picker.Element();
493
494        if (this.#picking)
495        {
496            this.#picking.removeEventListener("mousedown", this.#Clicked);
497            this.#picking = null;
498        }
499
500        if (!on || !element?.addEventListener) return;
501
502        this.#Clicked ??= (event) => this.#Click(event);
503        element.addEventListener("mousedown", this.#Clicked);
504        this.#picking = element;
505    }
506
507    #Clicked = null;
508
509    #Aim()
510    {
511        const gizmo = this.#gizmo;
512
513        if (!gizmo) return;
514
515        const eyes = this.#Eyes()?.eyes ?? [];
516        const eye = eyes[Number(this.#eye.value)] ?? eyes[0] ?? null;
517
518        gizmo.SetTarget(eye?.container ?? null);
519
520        if (eye) gizmo.Activate(this.#mode.value);
521        else gizmo.Deactivate();
522    }
523
524    async #SetEditing(editing)
525    {
526        this.#edit.checked = editing;
527
528        const ship = this.Ship();
529
530        if (!ship)
531        {
532            this.#Report("no ship");
533
534            return;
535        }
536
537        if (!editing)
538        {
539            this.#gizmo?.Deactivate();
540            this.#gizmo?.SetTarget(null);
541            this.#SetPicking(false);
542            this.#Report();
543
544            return;
545        }
546
547        this.Renderer()?.SetSpinning?.(false);
548
549        this.#LoadSize();
550
551        if (!this.#Eyes())
552        {
553            const existing = this.#Set()?.locators ?? [];
554
555            this.#Rebuild(existing.length ? existing : [ this.#NewLocator() ]);
556        }
557        else
558        {
559            this.#Eyelist(this.#Eyes().eyes.length);
560        }
561
562        const gizmo = this.#Gizmo();
563
564        this.#SetPicking(true);
565        this.#Aim();
566
567        if (gizmo) this.#Report();
568    }
569
570    #Set()
571    {
572        return this.Ship()?.locatorSets?.find(set => set?.name === GOOGLY_EYE_SET) ?? null;
573    }
574
575    #NewLocator()
576    {
577        const ship = this.Ship();
578        const radius = Number(ship?.boundingSphereRadius) || 20;
579
580        return {
581            position: [ 0, 0, radius * 0.5 + NEW_EYE_OFFSET ],
582            rotation: [ 0, 0, 0, 1 ],
583            scaling: [ 1, 1, 1 ],
584            boneIndex: -1
585        };
586    }
587
588    Add()
589    {
590        if (!this.#edit.checked) return;
591
592        const locators = this.#Locators();
593
594        locators.push(this.#NewLocator());
595        this.#eye.value = String(locators.length - 1);
596        this.#Touch();
597        this.#Rebuild(locators);
598    }
599
600    Mirror()
601    {
602        if (!this.#edit.checked) return;
603
604        const locators = this.#Locators();
605        const source = locators[Number(this.#eye.value)];
606
607        if (!source) return;
608
609        locators.push({
610            position: [ -source.position[0], source.position[1], source.position[2] ],
611            rotation: [ -source.rotation[0], source.rotation[1], source.rotation[2], -source.rotation[3] ],
612            scaling: [ ...source.scaling ],
613            boneIndex: -1
614        });
615
616        this.#eye.value = String(locators.length - 1);
617        this.#Touch();
618        this.#Rebuild(locators);
619    }
620
621    Drop()
622    {
623        if (!this.#edit.checked) return;
624
625        const locators = this.#Locators();
626        const at = Number(this.#eye.value);
627
628        if (at < 0 || at >= locators.length) return;
629
630        locators.splice(at, 1);
631        this.#eye.value = String(Math.max(0, at - 1));
632        this.#Touch();
633        this.#Rebuild(locators);
634    }
635
636    #Resize()
637    {
638        if (!this.#edit.checked) return;
639
640        this.#Touch();
641        this.#Rebuild(this.#Locators());
642    }
643
644    async Save()
645    {
646        const hull = this.#scope.value;
647
648        if (!hull) return;
649
650        const locators = this.#Locators();
651
652        this.#Report("saving...");
653
654        try
655        {
656            const response = await fetch(`/api/placements/${encodeURIComponent(hull)}/${GOOGLY_EYE_SET}`, {
657                method: "PUT",
658                credentials: "same-origin",
659                headers: { "content-type": "application/json" },
660                body: JSON.stringify({ locators, settings: { eyeRadius: this.#Radius(), gravity: this.#Gravity() } })
661            });
662
663            if (!response.ok) throw new Error(`HTTP ${response.status}`);
664
665            ForgetPlacements(hull);
666
667            ForgetEditor();
668            this.#Relatives(hull);
669            this.#dirty = false;
670            this.#Report(`saved ${locators.length} to ${hull}`);
671        }
672        catch (err)
673        {
674
675            this.#Report(`save failed: ${err.message}`);
676        }
677    }
678
679    Revert()
680    {
681        this.#dirty = false;
682
683        this.#LoadSize();
684        this.#Rebuild(this.#Set()?.locators?.map(locator => ({
685            position: [ ...locator.position ],
686            rotation: [ ...locator.rotation ],
687            scaling: [ ...locator.scaling ],
688            boneIndex: locator.boneIndex ?? -1
689        })) ?? []);
690    }
691
692    #Touch()
693    {
694        this.#dirty = true;
695    }
696
697    #Report(message = null)
698    {
699        const count = this.#Eyes()?.eyes?.length ?? 0;
700
701        this.#status.textContent = message
702            ?? (this.#edit.checked ? `${count} eye${count === 1 ? "" : "s"}${this.#dirty ? " - unsaved" : ""}` : "");
703    }
704
705}
706
707export default PlacementControl;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.