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.