diff --git a/.gitignore b/.gitignore index dc822826..04aa77f9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ out*/ .elasticbeanstalk/* !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml + +# mujoco wasm setup +!/public/dist +!/public/node_modules/three/build diff --git a/frontend/index.html b/frontend/index.html index f79d7af8..23892938 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,6 +23,21 @@ + + + + + + + + + - \ No newline at end of file + diff --git a/frontend/public/examples/main.js b/frontend/public/examples/main.js new file mode 100644 index 00000000..eb3f0cf7 --- /dev/null +++ b/frontend/public/examples/main.js @@ -0,0 +1,518 @@ +import * as THREE from "three"; + +import load_mujoco from "../dist/mujoco_wasm.js"; +import { OrbitControls } from "../node_modules/three/examples/jsm/controls/OrbitControls.js"; +import { GUI } from "../node_modules/three/examples/jsm/libs/lil-gui.module.min.js"; +import { + downloadExampleScenesFolder, + getPosition, + getQuaternion, + loadSceneFromURL, + setupGUI, + standardNormal, + toMujocoPos, +} from "./mujocoUtils.js"; +import { DragStateManager } from "./utils/DragStateManager.js"; + +// START Constants + +const HUMANOID_MODELS = Object.freeze({ + HUMANOID: "humanoid.xml", + STOMPY_PRO: "stompypro.xml", +}); + +// Load the MuJoCo Module +const mujoco = await load_mujoco(); + +// END Constants + +// Set up Emscripten's Virtual File System +var initialScene = HUMANOID_MODELS.STOMPY_PRO; +mujoco.FS.mkdir("/working"); +mujoco.FS.mount(mujoco.MEMFS, { root: "." }, "/working"); +mujoco.FS.writeFile( + "/working/" + initialScene, + await (await fetch("./examples/scenes/" + initialScene)).text(), +); + +// Create meshes directory +mujoco.FS.mkdir("/working/meshes"); + +export class MuJoCoDemo { + constructor() { + this.mujoco = mujoco; + + // Load in the state from XML + this.model = new mujoco.Model("/working/" + initialScene); + this.state = new mujoco.State(this.model); + this.simulation = new mujoco.Simulation(this.model, this.state); + + // Define Random State Variables + this.params = { + scene: initialScene, + paused: false, + useModel: true, + help: false, + ctrlnoiserate: 0.0, + ctrlnoisestd: 0.0, + keyframeNumber: 0, + }; + this.mujoco_time = 0.0; + (this.bodies = {}), (this.lights = {}); + this.tmpVec = new THREE.Vector3(); + this.tmpQuat = new THREE.Quaternion(); + this.updateGUICallbacks = []; + + // Adds to bottom of page + // this.container = document.createElement( 'div' ); + // document.body.appendChild( this.container ); + + // get viewport + this.container = document.getElementById("appbody"); + this.width = this.container.clientWidth; + this.height = this.container.clientHeight; + + this.scene = new THREE.Scene(); + this.scene.name = "scene"; + + // this.camera = new THREE.PerspectiveCamera( + // 75, + // this.width / this.height, + // 0.1, + // 1000, + // ); + this.camera = new THREE.PerspectiveCamera( + 45, + window.innerWidth / window.innerHeight, + 0.001, + 100, + ); + this.camera.name = "PerspectiveCamera"; + this.camera.position.set(2.0, 1.7, 1.7); + this.scene.add(this.camera); + + this.scene.background = new THREE.Color(0.15, 0.25, 0.35); + this.scene.fog = new THREE.Fog(this.scene.background, 15, 25.5); + + this.ambientLight = new THREE.AmbientLight(0xffffff, 0.1); + this.ambientLight.name = "AmbientLight"; + this.scene.add(this.ambientLight); + + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.renderer.setSize(this.width, this.height); + // this.renderer.setPixelRatio( window.devicePixelRatio ); + // this.renderer.setSize( window.innerWidth, window.innerHeight ); + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; // default THREE.PCFShadowMap + this.renderer.setAnimationLoop(this.render.bind(this)); + + this.container.appendChild(this.renderer.domElement); + + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.target.set(0, 0.7, 0); + this.controls.panSpeed = 2; + this.controls.zoomSpeed = 1; + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.1; + this.controls.screenSpacePanning = true; + this.controls.update(); + + this.actuatorNames = []; + this.actuatorRanges = []; + this.loadPPOModel(); + this.isSimulationReady = false; + + window.addEventListener("resize", this.onWindowResize.bind(this)); + + // Initialize the Drag State Manager. + this.dragStateManager = new DragStateManager( + this.scene, + this.renderer, + this.camera, + this.container.parentElement, + this.controls, + ); + document.addEventListener("keydown", this.handleKeyPress.bind(this)); + } + + async init() { + // Download the the examples to MuJoCo's virtual file system + await downloadExampleScenesFolder(mujoco); + + // Initialize the three.js Scene using the .xml Model in initialScene + [this.model, this.state, this.simulation, this.bodies, this.lights] = + await loadSceneFromURL(mujoco, initialScene, this); + + this.gui = new GUI(); + setupGUI(this); + this.isSimulationReady = true; + + // this.initializeActuators(); + } + + // does this ordering align with the ordering of the model output? + initializeActuators() { + const textDecoder = new TextDecoder(); + for (let i = 0; i < this.model.nu; i++) { + if (!this.model.actuator_ctrllimited[i]) { + continue; + } + let name = textDecoder + .decode(this.model.names.subarray(this.model.name_actuatoradr[i])) + .split("\0")[0]; + this.actuatorNames.push(name); + this.actuatorRanges.push([ + this.model.actuator_ctrlrange[2 * i], + this.model.actuator_ctrlrange[2 * i + 1], + ]); + } + } + + // TODO: load ONNX model + async loadPPOModel() { + this.ppo_model = null; + this.getObservation = null; + + switch (this.params["scene"]) { + case HUMANOID_MODELS.STOMPY_PRO: + break; + default: + throw new Error(`Unsupported model: ${this.params["scene"]}`); + } + } + + getObservationSkeleton(qpos_slice, cinert_slice, cvel_slice) { + const qpos = this.simulation.qpos.slice(qpos_slice); + const qvel = this.simulation.qvel; + const cinert = + cinert_slice !== -1 ? this.simulation.cinert.slice(cinert_slice) : []; + const cvel = + cvel_slice !== -1 ? this.simulation.cvel.slice(cvel_slice) : []; + const qfrc_actuator = this.simulation.qfrc_actuator; + + // console.log('qpos length:', qpos.length); + // console.log('qvel length:', qvel.length); + // console.log('cinert length:', cinert.length); + // console.log('cvel length:', cvel.length); + // console.log('qfrc_actuator length:', qfrc_actuator.length); + + const obsComponents = [ + ...qpos, + ...qvel, + ...cinert, + ...cvel, + ...qfrc_actuator, + ]; + + return obsComponents; + } + + handleKeyPress(event) { + const key = event.key.toLowerCase(); + const stepSize = 0.1; + + switch (key) { + case "q": + this.moveActuator("hip_y", stepSize); + break; + case "a": + this.moveActuator("hip_y_", -stepSize); + break; + case "w": + this.moveActuator("hip_", stepSize); + break; + case "s": + this.moveActuator("hip_", -stepSize); + break; + case "e": + this.moveActuator("knee_", stepSize); + break; + case "d": + this.moveActuator("knee_", -stepSize); + break; + case "r": + this.moveActuator("abdomen_y", stepSize); + break; + case "f": + this.moveActuator("abdomen_y", -stepSize); + break; + case "t": + this.moveActuator("ankle_", stepSize); + break; + case "g": + this.moveActuator("ankle_", -stepSize); + break; + case "y": + this.moveActuator("shoulder1_", stepSize); + this.moveActuator("shoulder2_", stepSize); + break; + case "h": + this.moveActuator("shoulder1_", -stepSize); + this.moveActuator("shoulder2_", -stepSize); + break; + case "u": + this.moveActuator("elbow_", stepSize); + break; + case "j": + this.moveActuator("elbow_", -stepSize); + break; + } + } + + moveActuator(prefix, amount) { + for (let i = 0; i < this.actuatorNames.length; i++) { + if (this.actuatorNames[i].startsWith(prefix)) { + let currentValue = this.simulation.ctrl[i]; + let [min, max] = this.actuatorRanges[i]; + let newValue = Math.max(min, Math.min(max, currentValue + amount)); + this.simulation.ctrl[i] = newValue; + this.params[this.actuatorNames[i]] = newValue; + } + } + } + + onWindowResize() { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + + // render loop + render(timeMS) { + if (!this.isSimulationReady) { + console.log("Simulation not ready yet, skipping render"); + return; + } + + this.controls.update(); + + if (!this.params["paused"]) { + // TODO: overhaul with ONNX logic + if (this.ppo_model && this.params["useModel"]) { + const observationArray = this.getObservation(); + const inputTensor = tf.tensor2d([observationArray]); + const resultTensor = this.ppo_model.predict(inputTensor); + + resultTensor.data().then((data) => { + // console.log('Model output:', data); + + // Assuming the model output corresponds to actuator values + for (let i = 0; i < data.length; i++) { + // Ensure the actuator index is within bounds + if (i < this.simulation.ctrl.length) { + let clippedValue = Math.max(-1, Math.min(1, data[i])); + + let [min, max] = this.actuatorRanges[i]; + + // Scale to fit between min and max + let newValue = min + ((clippedValue + 1) * (max - min)) / 2; + + // Update the actuator value + this.simulation.ctrl[i] = newValue; + + // Optionally, update the corresponding parameter + this.params[this.actuatorNames[i]] = newValue; + } else { + console.error("Model output index out of bounds:", i); + } + } + }); + } + + let timestep = this.model.getOptions().timestep; + if (timeMS - this.mujoco_time > 35.0) { + this.mujoco_time = timeMS; + } + while (this.mujoco_time < timeMS) { + // updates states from dragging + // Jitter the control state with gaussian random noise + if (this.params["ctrlnoisestd"] > 0.0) { + let rate = Math.exp( + -timestep / Math.max(1e-10, this.params["ctrlnoiserate"]), + ); + let scale = this.params["ctrlnoisestd"] * Math.sqrt(1 - rate * rate); + let currentCtrl = this.simulation.ctrl; + for (let i = 0; i < currentCtrl.length; i++) { + currentCtrl[i] = rate * currentCtrl[i] + scale * standardNormal(); + this.params[this.actuatorNames[i]] = currentCtrl[i]; + } + } + + // Clear old perturbations, apply new ones. + for (let i = 0; i < this.simulation.qfrc_applied.length; i++) { + this.simulation.qfrc_applied[i] = 0.0; + } + let dragged = this.dragStateManager.physicsObject; + if (dragged && dragged.bodyID) { + for (let b = 0; b < this.model.nbody; b++) { + if (this.bodies[b]) { + getPosition(this.simulation.xpos, b, this.bodies[b].position); + getQuaternion( + this.simulation.xquat, + b, + this.bodies[b].quaternion, + ); + this.bodies[b].updateWorldMatrix(); + } + } + let bodyID = dragged.bodyID; + this.dragStateManager.update(); // Update the world-space force origin + let force = toMujocoPos( + this.dragStateManager.currentWorld + .clone() + .sub(this.dragStateManager.worldHit) + .multiplyScalar(this.model.body_mass[bodyID] * 250), + ); + let point = toMujocoPos(this.dragStateManager.worldHit.clone()); + this.simulation.applyForce( + force.x, + force.y, + force.z, + 0, + 0, + 0, + point.x, + point.y, + point.z, + bodyID, + ); + + // TODO: Apply pose perturbations (mocap bodies only). + } + + this.simulation.step(); + + this.mujoco_time += timestep * 1000.0; + } + } else if (this.params["paused"]) { + // updates states from dragging + this.dragStateManager.update(); // Update the world-space force origin + let dragged = this.dragStateManager.physicsObject; + if (dragged && dragged.bodyID) { + let b = dragged.bodyID; + getPosition(this.simulation.xpos, b, this.tmpVec, false); // Get raw coordinate from MuJoCo + getQuaternion(this.simulation.xquat, b, this.tmpQuat, false); // Get raw coordinate from MuJoCo + + let offset = toMujocoPos( + this.dragStateManager.currentWorld + .clone() + .sub(this.dragStateManager.worldHit) + .multiplyScalar(0.3), + ); + if (this.model.body_mocapid[b] >= 0) { + // Set the root body's mocap position... + console.log("Trying to move mocap body", b); + let addr = this.model.body_mocapid[b] * 3; + let pos = this.simulation.mocap_pos; + pos[addr + 0] += offset.x; + pos[addr + 1] += offset.y; + pos[addr + 2] += offset.z; + } else { + // Set the root body's position directly... + let root = this.model.body_rootid[b]; + let addr = this.model.jnt_qposadr[this.model.body_jntadr[root]]; + let pos = this.simulation.qpos; + pos[addr + 0] += offset.x; + pos[addr + 1] += offset.y; + pos[addr + 2] += offset.z; + } + } + + this.simulation.forward(); + } + + // Update body transforms. + for (let b = 0; b < this.model.nbody; b++) { + if (this.bodies[b]) { + getPosition(this.simulation.xpos, b, this.bodies[b].position); + getQuaternion(this.simulation.xquat, b, this.bodies[b].quaternion); + this.bodies[b].updateWorldMatrix(); + } + } + + // Update light transforms. + for (let l = 0; l < this.model.nlight; l++) { + if (this.lights[l]) { + getPosition(this.simulation.light_xpos, l, this.lights[l].position); + getPosition(this.simulation.light_xdir, l, this.tmpVec); + this.lights[l].lookAt(this.tmpVec.add(this.lights[l].position)); + } + } + + // Update tendon transforms. + let numWraps = 0; + if (this.mujocoRoot && this.mujocoRoot.cylinders) { + let mat = new THREE.Matrix4(); + for (let t = 0; t < this.model.ntendon; t++) { + let startW = this.simulation.ten_wrapadr[t]; + let r = this.model.tendon_width[t]; + for ( + let w = startW; + w < startW + this.simulation.ten_wrapnum[t] - 1; + w++ + ) { + let tendonStart = getPosition( + this.simulation.wrap_xpos, + w, + new THREE.Vector3(), + ); + let tendonEnd = getPosition( + this.simulation.wrap_xpos, + w + 1, + new THREE.Vector3(), + ); + let tendonAvg = new THREE.Vector3() + .addVectors(tendonStart, tendonEnd) + .multiplyScalar(0.5); + + let validStart = tendonStart.length() > 0.01; + let validEnd = tendonEnd.length() > 0.01; + + if (validStart) { + this.mujocoRoot.spheres.setMatrixAt( + numWraps, + mat.compose( + tendonStart, + new THREE.Quaternion(), + new THREE.Vector3(r, r, r), + ), + ); + } + if (validEnd) { + this.mujocoRoot.spheres.setMatrixAt( + numWraps + 1, + mat.compose( + tendonEnd, + new THREE.Quaternion(), + new THREE.Vector3(r, r, r), + ), + ); + } + if (validStart && validEnd) { + mat.compose( + tendonAvg, + new THREE.Quaternion().setFromUnitVectors( + new THREE.Vector3(0, 1, 0), + tendonEnd.clone().sub(tendonStart).normalize(), + ), + new THREE.Vector3(r, tendonStart.distanceTo(tendonEnd), r), + ); + this.mujocoRoot.cylinders.setMatrixAt(numWraps, mat); + numWraps++; + } + } + } + this.mujocoRoot.cylinders.count = numWraps; + this.mujocoRoot.spheres.count = numWraps > 0 ? numWraps + 1 : 0; + this.mujocoRoot.cylinders.instanceMatrix.needsUpdate = true; + this.mujocoRoot.spheres.instanceMatrix.needsUpdate = true; + } + + // Render! + this.renderer.render(this.scene, this.camera); + } +} + +let demo = new MuJoCoDemo(); +await demo.init(); diff --git a/frontend/public/examples/mujocoUtils.js b/frontend/public/examples/mujocoUtils.js new file mode 100644 index 00000000..790067c1 --- /dev/null +++ b/frontend/public/examples/mujocoUtils.js @@ -0,0 +1,1052 @@ +import * as THREE from "three"; + +import { MuJoCoDemo } from "./main.js"; +import { Reflector } from "./utils/Reflector.js"; + +export async function reloadFunc() { + // Delete the old scene and load the new scene + this.scene.remove(this.scene.getObjectByName("MuJoCo Root")); + [this.model, this.state, this.simulation, this.bodies, this.lights] = + await loadSceneFromURL(this.mujoco, this.params.scene, this); + + // console.log(this.model, this.state, this.simulation, this.bodies, this.lights); + + this.simulation.forward(); + for (let i = 0; i < this.updateGUICallbacks.length; i++) { + this.updateGUICallbacks[i](this.model, this.simulation, this.params); + } +} + +/** @param {MuJoCoDemo} parentContext*/ +export function setupGUI(parentContext) { + // Make sure we reset the camera when the scene is changed or reloaded. + parentContext.updateGUICallbacks.length = 0; + parentContext.updateGUICallbacks.push((model, simulation, params) => { + // TODO: Use free camera parameters from MuJoCo + parentContext.camera.position.set(2.0, 1.7, 1.7); + parentContext.controls.target.set(0, 0.7, 0); + parentContext.controls.update(); + }); + + parentContext.allScenes = { + Humanoid: "humanoid.xml", + }; + + // Add scene selection dropdown. + let reload = reloadFunc.bind(parentContext); + let sceneDropdown = parentContext.gui + .add(parentContext.params, "scene", parentContext.allScenes) + .name("Example Scene") + .onChange(reload); + + // Add upload button + let uploadButton = { + upload: function () { + let input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.accept = ".xml,.obj,.stl"; + input.onchange = async function (event) { + let files = event.target.files; + let xmlFile = null; + let meshFiles = []; + let newSceneName = ""; + + for (let file of files) { + if (file.name.endsWith(".xml")) { + xmlFile = file; + newSceneName = file.name.split(".")[0]; + } else { + meshFiles.push(file); + } + } + + if (!xmlFile) { + alert("Please include an XML file."); + return; + } + + // Create 'working' directory if it doesn't exist + if (!parentContext.mujoco.FS.analyzePath("/working").exists) { + parentContext.mujoco.FS.mkdir("/working"); + } + + // Write XML file + let xmlContent = await xmlFile.arrayBuffer(); + parentContext.mujoco.FS.writeFile( + `/working/${xmlFile.name}`, + new Uint8Array(xmlContent), + ); + + // Write mesh files + for (let meshFile of meshFiles) { + let meshContent = await meshFile.arrayBuffer(); + parentContext.mujoco.FS.writeFile( + `/working/${meshFile.name}`, + new Uint8Array(meshContent), + ); + } + + // Update scene dropdown + parentContext.allScenes[newSceneName] = xmlFile.name; + updateSceneDropdown(sceneDropdown, parentContext.allScenes); + + parentContext.params.scene = xmlFile.name; + + console.log( + `Uploaded ${xmlFile.name} and ${meshFiles.length} mesh file(s)`, + ); + // alert(`Uploaded ${xmlFile.name} and ${meshFiles.length} mesh file(s)`); + + // Trigger a reload of the scene + reload(); + }; + input.click(); + }, + }; + + parentContext.gui.add(uploadButton, "upload").name("Upload Scene"); + + // Add a help menu. + // Parameters: + // Name: "Help". + // When pressed, a help menu is displayed in the top left corner. When pressed again + // the help menu is removed. + // Can also be triggered by pressing F1. + // Has a dark transparent background. + // Has two columns: one for putting the action description, and one for the action key trigger.keyframeNumber + let keyInnerHTML = ""; + let actionInnerHTML = ""; + const displayHelpMenu = () => { + if (parentContext.params.help) { + const helpMenu = document.createElement("div"); + helpMenu.style.position = "absolute"; + helpMenu.style.top = "10px"; + helpMenu.style.left = "10px"; + helpMenu.style.color = "white"; + helpMenu.style.font = "normal 18px Arial"; + helpMenu.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; + helpMenu.style.padding = "10px"; + helpMenu.style.borderRadius = "10px"; + helpMenu.style.display = "flex"; + helpMenu.style.flexDirection = "column"; + helpMenu.style.alignItems = "center"; + helpMenu.style.justifyContent = "center"; + helpMenu.style.width = "400px"; + helpMenu.style.height = "400px"; + helpMenu.style.overflow = "auto"; + helpMenu.style.zIndex = "1000"; + + const helpMenuTitle = document.createElement("div"); + helpMenuTitle.style.font = "bold 24px Arial"; + helpMenuTitle.innerHTML = ""; + helpMenu.appendChild(helpMenuTitle); + + const helpMenuTable = document.createElement("table"); + helpMenuTable.style.width = "100%"; + helpMenuTable.style.marginTop = "10px"; + helpMenu.appendChild(helpMenuTable); + + const helpMenuTableBody = document.createElement("tbody"); + helpMenuTable.appendChild(helpMenuTableBody); + + const helpMenuRow = document.createElement("tr"); + helpMenuTableBody.appendChild(helpMenuRow); + + const helpMenuActionColumn = document.createElement("td"); + helpMenuActionColumn.style.width = "50%"; + helpMenuActionColumn.style.textAlign = "right"; + helpMenuActionColumn.style.paddingRight = "10px"; + helpMenuRow.appendChild(helpMenuActionColumn); + + const helpMenuKeyColumn = document.createElement("td"); + helpMenuKeyColumn.style.width = "50%"; + helpMenuKeyColumn.style.textAlign = "left"; + helpMenuKeyColumn.style.paddingLeft = "10px"; + helpMenuRow.appendChild(helpMenuKeyColumn); + + const helpMenuActionText = document.createElement("div"); + helpMenuActionText.innerHTML = actionInnerHTML; + helpMenuActionColumn.appendChild(helpMenuActionText); + + const helpMenuKeyText = document.createElement("div"); + helpMenuKeyText.innerHTML = keyInnerHTML; + helpMenuKeyColumn.appendChild(helpMenuKeyText); + + // Close buttom in the top. + const helpMenuCloseButton = document.createElement("button"); + helpMenuCloseButton.innerHTML = "Close"; + helpMenuCloseButton.style.position = "absolute"; + helpMenuCloseButton.style.top = "10px"; + helpMenuCloseButton.style.right = "10px"; + helpMenuCloseButton.style.zIndex = "1001"; + helpMenuCloseButton.onclick = () => { + helpMenu.remove(); + }; + helpMenu.appendChild(helpMenuCloseButton); + + document.body.appendChild(helpMenu); + } else { + document.body.removeChild(document.body.lastChild); + } + }; + + document.addEventListener("keydown", (event) => { + if (event.key === "F1") { + parentContext.params.help = !parentContext.params.help; + displayHelpMenu(); + event.preventDefault(); + } + }); + keyInnerHTML += "F1
"; + actionInnerHTML += "Help
"; + + let simulationFolder = parentContext.gui.addFolder("Simulation"); + + // Add pause simulation checkbox. + // Parameters: + // Under "Simulation" folder. + // Name: "Pause Simulation". + // When paused, a "pause" text in white is displayed in the top left corner. + // Can also be triggered by pressing the spacebar. + const pauseSimulation = simulationFolder + .add(parentContext.params, "paused") + .name("Pause Simulation"); + pauseSimulation.onChange((value) => { + if (value) { + const pausedText = document.createElement("div"); + pausedText.style.position = "absolute"; + pausedText.style.top = "10px"; + pausedText.style.left = "10px"; + pausedText.style.color = "white"; + pausedText.style.font = "normal 18px Arial"; + pausedText.innerHTML = "pause"; + parentContext.container.appendChild(pausedText); + } else { + parentContext.container.removeChild(parentContext.container.lastChild); + } + }); + document.addEventListener("keydown", (event) => { + if (event.code === "Space") { + parentContext.params.paused = !parentContext.params.paused; + pauseSimulation.setValue(parentContext.params.paused); + event.preventDefault(); + } + }); + actionInnerHTML += "Play / Pause
"; + keyInnerHTML += "Space
"; + + // Add enable / disable model checkbox. + document.addEventListener("keydown", (event) => { + if (event.ctrlKey && event.code === "KeyM") { + parentContext.params.useModel = !parentContext.params.useModel; + event.preventDefault(); + } + }); + actionInnerHTML += "Enable / Disable Model
"; + keyInnerHTML += "Ctrl M
"; + + // Add reload model button. + // Parameters: + // Under "Simulation" folder. + // Name: "Reload". + // When pressed, calls the reload function. + // Can also be triggered by pressing ctrl + L. + simulationFolder + .add( + { + reload: () => { + reload(); + }, + }, + "reload", + ) + .name("Reload"); + document.addEventListener("keydown", (event) => { + if (event.ctrlKey && event.code === "KeyL") { + reload(); + event.preventDefault(); + } + }); + actionInnerHTML += "Reload XML
"; + keyInnerHTML += "Ctrl L
"; + + // Add reset simulation button. + // Parameters: + // Under "Simulation" folder. + // Name: "Reset". + // When pressed, resets the simulation to the initial state. + // Can also be triggered by pressing backspace. + const resetSimulation = () => { + parentContext.simulation.resetData(); + parentContext.simulation.forward(); + }; + simulationFolder + .add( + { + reset: () => { + resetSimulation(); + }, + }, + "reset", + ) + .name("Reset"); + document.addEventListener("keydown", (event) => { + if (event.code === "Backspace") { + resetSimulation(); + event.preventDefault(); + } + }); + actionInnerHTML += "Reset simulation
"; + keyInnerHTML += "Backspace
"; + + // Add keyframe slider. + let nkeys = parentContext.model.nkey; + let keyframeGUI = simulationFolder + .add(parentContext.params, "keyframeNumber", 0, nkeys - 1, 1) + .name("Load Keyframe") + .listen(); + keyframeGUI.onChange((value) => { + if (value < parentContext.model.nkey) { + parentContext.simulation.qpos.set( + parentContext.model.key_qpos.slice( + value * parentContext.model.nq, + (value + 1) * parentContext.model.nq, + ), + ); + } + }); + parentContext.updateGUICallbacks.push((model, simulation, params) => { + let nkeys = parentContext.model.nkey; + console.log("new model loaded. has " + nkeys + " keyframes."); + if (nkeys > 0) { + keyframeGUI.max(nkeys - 1); + keyframeGUI.domElement.style.opacity = 1.0; + } else { + // Disable keyframe slider if no keyframes are available. + keyframeGUI.max(0); + keyframeGUI.domElement.style.opacity = 0.5; + } + }); + + // Add sliders for ctrlnoiserate and ctrlnoisestd; min = 0, max = 2, step = 0.01. + simulationFolder + .add(parentContext.params, "ctrlnoiserate", 0.0, 2.0, 0.01) + .name("Noise rate"); + simulationFolder + .add(parentContext.params, "ctrlnoisestd", 0.0, 2.0, 0.01) + .name("Noise scale"); + + let textDecoder = new TextDecoder("utf-8"); + let nullChar = textDecoder.decode(new ArrayBuffer(1)); + + // Add actuator sliders. + let actuatorFolder = simulationFolder.addFolder("Actuators"); + const addActuators = (model, simulation, params) => { + let act_range = model.actuator_ctrlrange; + let actuatorGUIs = []; + for (let i = 0; i < model.nu; i++) { + if (!model.actuator_ctrllimited[i]) { + continue; + } + let name = textDecoder + .decode( + parentContext.model.names.subarray( + parentContext.model.name_actuatoradr[i], + ), + ) + .split(nullChar)[0]; + + parentContext.params[name] = 0.0; + let actuatorGUI = actuatorFolder + .add( + parentContext.params, + name, + act_range[2 * i], + act_range[2 * i + 1], + 0.01, + ) + .name(name) + .listen(); + actuatorGUIs.push(actuatorGUI); + actuatorGUI.onChange((value) => { + console.log("value", value); + simulation.ctrl[i] = value; + }); + } + return actuatorGUIs; + }; + let actuatorGUIs = addActuators( + parentContext.model, + parentContext.simulation, + parentContext.params, + ); + parentContext.updateGUICallbacks.push((model, simulation, params) => { + for (let i = 0; i < actuatorGUIs.length; i++) { + actuatorGUIs[i].destroy(); + } + actuatorGUIs = addActuators(model, simulation, parentContext.params); + }); + actuatorFolder.close(); + + // Add function that resets the camera to the default position. + // Can be triggered by pressing ctrl + A. + document.addEventListener("keydown", (event) => { + console.log("event", event); + if (event.ctrlKey && event.code === "KeyA") { + // TODO: Use free camera parameters from MuJoCo + parentContext.camera.position.set(2.0, 1.7, 1.7); + parentContext.controls.target.set(0, 0.7, 0); + parentContext.controls.update(); + event.preventDefault(); + } + }); + actionInnerHTML += "Reset free camera
"; + keyInnerHTML += "Ctrl A
"; + + // Adjust the style of the GUI + const uiContainer = document.getElementById("mujoco-ui-container"); + if (uiContainer) { + uiContainer.appendChild(parentContext.gui.domElement); + parentContext.gui.domElement.style.position = "relative"; + parentContext.gui.domElement.style.top = "10px"; + parentContext.gui.domElement.style.right = "10px"; + } else { + console.warn( + "mujoco-ui-container not found. Falling back to fixed positioning.", + ); + parentContext.gui.domElement.style.position = "fixed"; + parentContext.gui.domElement.style.top = "10px"; + parentContext.gui.domElement.style.right = "10px"; + } + + // Add this at the end of the setupGUI function + const appBody = document.getElementById("appbody"); + if (appBody) { + const urdfUrl = appBody.getAttribute("data-urdf-url"); + if (urdfUrl) { + autoUploadURDFTar(parentContext, urdfUrl, reload); + } + } + + parentContext.gui.open(); +} + +function updateSceneDropdown(dropdown, scenes) { + // Store the current onChange function + let onChangeFunc = dropdown.__onChange; + + // Remove all options + if (dropdown.__elect && dropdown.__select.options) { + dropdown.__select.options.length = 0; + } + + console.log(scenes); + dropdown.__select = document.createElement("select"); + + // Add new options + for (let [name, file] of Object.entries(scenes)) { + let option = document.createElement("option"); + option.text = name; + option.value = file; + dropdown.__select.add(option); + } + + // Restore the onChange function + dropdown.__onChange = onChangeFunc; +} + +/** Loads a scene for MuJoCo + * @param {mujoco} mujoco This is a reference to the mujoco namespace object + * @param {string} filename This is the name of the .xml file in the /working/ directory of the MuJoCo/Emscripten Virtual File System + * @param {MuJoCoDemo} parent The three.js Scene Object to add the MuJoCo model elements to + */ +export async function loadSceneFromURL(mujoco, filename, parent) { + // Free the old simulation. + if (parent.simulation != null) { + parent.simulation.free(); + parent.model = null; + parent.state = null; + parent.simulation = null; + parent.ppo_model = null; + } + + // Function to capture and log errors + mujoco.mj_error = function (msg) { + console.error("MuJoCo Error: " + msg); + }; + + // Function to capture and log warnings + mujoco.mj_warning = function (msg) { + console.warn("MuJoCo Warning: " + msg); + }; + + // Load in the state from XML. + try { + console.log("loading model from", "/working/" + filename); + parent.model = mujoco.Model.load_from_xml("/working/" + filename); + } catch (error) { + console.error("Failed to load model:", error); + } + parent.state = new mujoco.State(parent.model); + parent.simulation = new mujoco.Simulation(parent.model, parent.state); + + parent.actuatorNames = []; + parent.actuatorRanges = []; + parent.loadPPOModel(); + parent.initializeActuators(); + + let model = parent.model; + let state = parent.state; + let simulation = parent.simulation; + + // Decode the null-terminated string names. + let textDecoder = new TextDecoder("utf-8"); + let fullString = textDecoder.decode(model.names); + let names = fullString.split(textDecoder.decode(new ArrayBuffer(1))); + + // Create the root object. + let mujocoRoot = new THREE.Group(); + mujocoRoot.name = "MuJoCo Root"; + parent.scene.add(mujocoRoot); + + /** @type {Object.} */ + let bodies = {}; + /** @type {Object.} */ + let meshes = {}; + /** @type {THREE.Light[]} */ + let lights = []; + + // Default material definition. + let material = new THREE.MeshPhysicalMaterial(); + material.color = new THREE.Color(1, 1, 1); + + // Loop through the MuJoCo geoms and recreate them in three.js. + for (let g = 0; g < model.ngeom; g++) { + // Only visualize geom groups up to 2 (same default behavior as simulate). + if (!(model.geom_group[g] < 3)) { + continue; + } + + // Get the body ID and type of the geom. + let b = model.geom_bodyid[g]; + let type = model.geom_type[g]; + let size = [ + model.geom_size[g * 3 + 0], + model.geom_size[g * 3 + 1], + model.geom_size[g * 3 + 2], + ]; + + // Create the body if it doesn't exist. + if (!(b in bodies)) { + bodies[b] = new THREE.Group(); + bodies[b].name = names[model.name_bodyadr[b]]; + bodies[b].bodyID = b; + bodies[b].has_custom_mesh = false; + } + + // Set the default geometry. In MuJoCo, this is a sphere. + let geometry = new THREE.SphereGeometry(size[0] * 0.5); + if (type == mujoco.mjtGeom.mjGEOM_PLANE.value) { + // Special handling for plane later. + } else if (type == mujoco.mjtGeom.mjGEOM_HFIELD.value) { + // TODO: Implement this. + } else if (type == mujoco.mjtGeom.mjGEOM_SPHERE.value) { + geometry = new THREE.SphereGeometry(size[0]); + } else if (type == mujoco.mjtGeom.mjGEOM_CAPSULE.value) { + geometry = new THREE.CapsuleGeometry(size[0], size[1] * 2.0, 20, 20); + } else if (type == mujoco.mjtGeom.mjGEOM_ELLIPSOID.value) { + geometry = new THREE.SphereGeometry(1); // Stretch this below + } else if (type == mujoco.mjtGeom.mjGEOM_CYLINDER.value) { + geometry = new THREE.CylinderGeometry(size[0], size[0], size[1] * 2.0); + } else if (type == mujoco.mjtGeom.mjGEOM_BOX.value) { + geometry = new THREE.BoxGeometry( + size[0] * 2.0, + size[2] * 2.0, + size[1] * 2.0, + ); + } else if (type == mujoco.mjtGeom.mjGEOM_MESH.value) { + let meshID = model.geom_dataid[g]; + + if (!(meshID in meshes)) { + geometry = new THREE.BufferGeometry(); // TODO: Populate the Buffer Geometry with Generic Mesh Data + + let vertex_buffer = model.mesh_vert.subarray( + model.mesh_vertadr[meshID] * 3, + (model.mesh_vertadr[meshID] + model.mesh_vertnum[meshID]) * 3, + ); + for (let v = 0; v < vertex_buffer.length; v += 3) { + //vertex_buffer[v + 0] = vertex_buffer[v + 0]; + let temp = vertex_buffer[v + 1]; + vertex_buffer[v + 1] = vertex_buffer[v + 2]; + vertex_buffer[v + 2] = -temp; + } + + let normal_buffer = model.mesh_normal.subarray( + model.mesh_vertadr[meshID] * 3, + (model.mesh_vertadr[meshID] + model.mesh_vertnum[meshID]) * 3, + ); + for (let v = 0; v < normal_buffer.length; v += 3) { + //normal_buffer[v + 0] = normal_buffer[v + 0]; + let temp = normal_buffer[v + 1]; + normal_buffer[v + 1] = normal_buffer[v + 2]; + normal_buffer[v + 2] = -temp; + } + + let uv_buffer = model.mesh_texcoord.subarray( + model.mesh_texcoordadr[meshID] * 2, + (model.mesh_texcoordadr[meshID] + model.mesh_vertnum[meshID]) * 2, + ); + let triangle_buffer = model.mesh_face.subarray( + model.mesh_faceadr[meshID] * 3, + (model.mesh_faceadr[meshID] + model.mesh_facenum[meshID]) * 3, + ); + geometry.setAttribute( + "position", + new THREE.BufferAttribute(vertex_buffer, 3), + ); + geometry.setAttribute( + "normal", + new THREE.BufferAttribute(normal_buffer, 3), + ); + geometry.setAttribute("uv", new THREE.BufferAttribute(uv_buffer, 2)); + geometry.setIndex(Array.from(triangle_buffer)); + meshes[meshID] = geometry; + } else { + geometry = meshes[meshID]; + } + + bodies[b].has_custom_mesh = true; + } + // Done with geometry creation. + + // Set the Material Properties of incoming bodies + let texture = undefined; + let color = [ + model.geom_rgba[g * 4 + 0], + model.geom_rgba[g * 4 + 1], + model.geom_rgba[g * 4 + 2], + model.geom_rgba[g * 4 + 3], + ]; + if (model.geom_matid[g] != -1) { + let matId = model.geom_matid[g]; + color = [ + model.mat_rgba[matId * 4 + 0], + model.mat_rgba[matId * 4 + 1], + model.mat_rgba[matId * 4 + 2], + model.mat_rgba[matId * 4 + 3], + ]; + + // Construct Texture from model.tex_rgb + texture = undefined; + let texId = model.mat_texid[matId]; + if (texId != -1) { + let width = model.tex_width[texId]; + let height = model.tex_height[texId]; + let offset = model.tex_adr[texId]; + let rgbArray = model.tex_rgb; + let rgbaArray = new Uint8Array(width * height * 4); + for (let p = 0; p < width * height; p++) { + rgbaArray[p * 4 + 0] = rgbArray[offset + (p * 3 + 0)]; + rgbaArray[p * 4 + 1] = rgbArray[offset + (p * 3 + 1)]; + rgbaArray[p * 4 + 2] = rgbArray[offset + (p * 3 + 2)]; + rgbaArray[p * 4 + 3] = 1.0; + } + texture = new THREE.DataTexture( + rgbaArray, + width, + height, + THREE.RGBAFormat, + THREE.UnsignedByteType, + ); + if (texId == 2) { + texture.repeat = new THREE.Vector2(50, 50); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + } else { + texture.repeat = new THREE.Vector2(1, 1); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + } + + texture.needsUpdate = true; + } + } + + if ( + material.color.r != color[0] || + material.color.g != color[1] || + material.color.b != color[2] || + material.opacity != color[3] || + material.map != texture + ) { + material = new THREE.MeshPhysicalMaterial({ + color: new THREE.Color(color[0], color[1], color[2]), + transparent: color[3] < 1.0, + opacity: color[3], + specularIntensity: + model.geom_matid[g] != -1 + ? model.mat_specular[model.geom_matid[g]] * 0.5 + : undefined, + reflectivity: + model.geom_matid[g] != -1 + ? model.mat_reflectance[model.geom_matid[g]] + : undefined, + roughness: + model.geom_matid[g] != -1 + ? 1.0 - model.mat_shininess[model.geom_matid[g]] + : undefined, + metalness: model.geom_matid[g] != -1 ? 0.1 : undefined, + map: texture, + }); + } + + let mesh = new THREE.Mesh(); + if (type == 0) { + mesh = new Reflector(new THREE.PlaneGeometry(100, 100), { + clipBias: 0.003, + texture: texture, + }); + mesh.rotateX(-Math.PI / 2); + } else { + mesh = new THREE.Mesh(geometry, material); + } + + mesh.castShadow = g == 0 ? false : true; + mesh.receiveShadow = type != 7; + mesh.bodyID = b; + bodies[b].add(mesh); + getPosition(model.geom_pos, g, mesh.position); + if (type != 0) { + getQuaternion(model.geom_quat, g, mesh.quaternion); + } + if (type == 4) { + mesh.scale.set(size[0], size[2], size[1]); + } // Stretch the Ellipsoid + } + + // Parse tendons. + let tendonMat = new THREE.MeshPhongMaterial(); + tendonMat.color = new THREE.Color(0.8, 0.3, 0.3); + mujocoRoot.cylinders = new THREE.InstancedMesh( + new THREE.CylinderGeometry(1, 1, 1), + tendonMat, + 1023, + ); + mujocoRoot.cylinders.receiveShadow = true; + mujocoRoot.cylinders.castShadow = true; + mujocoRoot.add(mujocoRoot.cylinders); + mujocoRoot.spheres = new THREE.InstancedMesh( + new THREE.SphereGeometry(1, 10, 10), + tendonMat, + 1023, + ); + mujocoRoot.spheres.receiveShadow = true; + mujocoRoot.spheres.castShadow = true; + mujocoRoot.add(mujocoRoot.spheres); + + // Parse lights. + for (let l = 0; l < model.nlight; l++) { + let light = new THREE.SpotLight(); + if (model.light_directional[l]) { + light = new THREE.DirectionalLight(); + } else { + light = new THREE.SpotLight(); + } + light.decay = model.light_attenuation[l] * 100; + light.penumbra = 0.5; + light.castShadow = true; // default false + + light.shadow.mapSize.width = 1024; // default + light.shadow.mapSize.height = 1024; // default + light.shadow.camera.near = 1; // default + light.shadow.camera.far = 10; // default + //bodies[model.light_bodyid()].add(light); + if (bodies[0]) { + bodies[0].add(light); + } else { + mujocoRoot.add(light); + } + lights.push(light); + } + if (model.nlight == 0) { + let light = new THREE.DirectionalLight(); + mujocoRoot.add(light); + } + + for (let b = 0; b < model.nbody; b++) { + //let parent_body = model.body_parentid()[b]; + if (b == 0 || !bodies[0]) { + mujocoRoot.add(bodies[b]); + } else if (bodies[b]) { + bodies[0].add(bodies[b]); + } else { + console.log( + "Body without Geometry detected; adding to bodies", + b, + bodies[b], + ); + bodies[b] = new THREE.Group(); + bodies[b].name = names[b + 1]; + bodies[b].bodyID = b; + bodies[b].has_custom_mesh = false; + bodies[0].add(bodies[b]); + } + } + + parent.mujocoRoot = mujocoRoot; + + return [model, state, simulation, bodies, lights]; +} + +/** Downloads the scenes/examples folder to MuJoCo's virtual filesystem + * @param {mujoco} mujoco */ +export async function downloadExampleScenesFolder(mujoco) { + let allFiles = ["humanoid.xml", "scene.xml", "stompypro.xml"]; + + const meshFilesListStompyPro = [ + "buttock.stl", + "calf.stl", + "clav.stl", + "farm.stl", + "foot.stl", + "leg.stl", + "mcalf.stl", + "mfoot.stl", + "mthigh.stl", + "scap.stl", + "thigh.stl", + "trunk.stl", + "uarm.stl", + ]; + + allFiles = allFiles.concat( + meshFilesListStompyPro.map((mesh) => "/stompypro_meshes/" + mesh), + ); + + let requests = allFiles.map((url) => fetch("/examples/scenes/" + url)); + let responses = await Promise.all(requests); + + for (let i = 0; i < responses.length; i++) { + let split = allFiles[i].split("/"); + let working = "/working/"; + for (let f = 0; f < split.length - 1; f++) { + working += split[f]; + if (!mujoco.FS.analyzePath(working).exists) { + mujoco.FS.mkdir(working); + } + working += "/"; + } + + let filePath = "/working/" + allFiles[i]; + + if ( + allFiles[i].endsWith(".png") || + allFiles[i].endsWith(".stl") || + allFiles[i].endsWith(".skn") || + allFiles[i].endsWith(".STL") + ) { + mujoco.FS.writeFile( + filePath, + new Uint8Array(await responses[i].arrayBuffer()), + ); + } else { + mujoco.FS.writeFile(filePath, await responses[i].text()); + } + + // Check if the file exists in the Mujoco filesystem after writing + if (mujoco.FS.analyzePath(filePath).exists) { + // console.log(mujoco.FS.readFile(filePath, { encoding: 'utf8' })); + console.log(`File ${filePath} written successfully.`); + } else { + console.error(`Failed to write file ${filePath}.`); + } + } +} + +/** Access the vector at index, swizzle for three.js, and apply to the target THREE.Vector3 + * @param {Float32Array|Float64Array} buffer + * @param {number} index + * @param {THREE.Vector3} target */ +export function getPosition(buffer, index, target, swizzle = true) { + if (swizzle) { + return target.set( + buffer[index * 3 + 0], + buffer[index * 3 + 2], + -buffer[index * 3 + 1], + ); + } else { + return target.set( + buffer[index * 3 + 0], + buffer[index * 3 + 1], + buffer[index * 3 + 2], + ); + } +} + +/** Access the quaternion at index, swizzle for three.js, and apply to the target THREE.Quaternion + * @param {Float32Array|Float64Array} buffer + * @param {number} index + * @param {THREE.Quaternion} target */ +export function getQuaternion(buffer, index, target, swizzle = true) { + if (swizzle) { + return target.set( + -buffer[index * 4 + 1], + -buffer[index * 4 + 3], + buffer[index * 4 + 2], + -buffer[index * 4 + 0], + ); + } else { + return target.set( + buffer[index * 4 + 0], + buffer[index * 4 + 1], + buffer[index * 4 + 2], + buffer[index * 4 + 3], + ); + } +} + +/** Converts this Vector3's Handedness to MuJoCo's Coordinate Handedness + * @param {THREE.Vector3} target */ +export function toMujocoPos(target) { + return target.set(target.x, -target.z, target.y); +} + +/** Standard normal random number generator using Box-Muller transform */ +export function standardNormal() { + return ( + Math.sqrt(-2.0 * Math.log(Math.random())) * + Math.cos(2.0 * Math.PI * Math.random()) + ); +} + +/* For external interaction */ + +// Simple tar extraction function +function untar(arrayBuffer) { + const uint8Array = new Uint8Array(arrayBuffer); + const files = []; + let offset = 0; + + while (offset < uint8Array.length - 512) { + const header = uint8Array.slice(offset, offset + 512); + const fileName = new TextDecoder() + .decode(header.slice(0, 100)) + .replace(/\0/g, "") + .trim(); + + if (fileName.length === 0) { + break; // End of archive + } + + const fileSize = parseInt( + new TextDecoder().decode(header.slice(124, 136)).trim(), + 8, + ); + const contentStartIndex = offset + 512; + const contentEndIndex = contentStartIndex + fileSize; + const content = uint8Array.slice(contentStartIndex, contentEndIndex); + + files.push({ name: fileName, buffer: content.buffer }); + + offset = Math.ceil(contentEndIndex / 512) * 512; + } + + return files; +} + +export async function autoUploadURDFTar(parentContext, urdfTarUrl, reload) { + try { + console.log("Attempting to fetch:", urdfTarUrl); + + const response = await fetch(urdfTarUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const compressedData = await response.arrayBuffer(); + + // Decompress the gzip data + const inflatedData = pako.inflate(new Uint8Array(compressedData)); + + // Extract the tar data + const files = untar(inflatedData.buffer); + + let xmlFile = null; + let meshFiles = []; + let newSceneName = ""; + + // Process extracted files + for (const file of files) { + if (file.name.endsWith(".xml")) { + console.log("Found XML file:", file.name); + xmlFile = { + name: file.name, + content: file.buffer, + }; + newSceneName = file.name.split(".")[0]; + + // Log the content of the XML file + const xmlContent = new TextDecoder().decode( + new Uint8Array(file.buffer), + ); + console.log("XML file content:", xmlContent); + } else if ( + file.name.endsWith(".stl") || + file.name.endsWith(".obj") || + file.name.endsWith(".STL") || + file.name.endsWith(".OBJ") + ) { + meshFiles.push({ + name: file.name, + content: file.buffer, + }); + } else { + console.log("Skipping file:", file.name); + } + } + + if (!xmlFile) { + throw new Error("No XML file found in the URDF tar"); + } + + // Create 'working' directory if it doesn't exist + if (!parentContext.mujoco.FS.analyzePath("/working").exists) { + parentContext.mujoco.FS.mkdir("/working"); + } + + // Write XML file + parentContext.mujoco.FS.writeFile( + `/working/${xmlFile.name}`, + new Uint8Array(xmlFile.content), + ); + + // Write mesh files + for (let meshFile of meshFiles) { + parentContext.mujoco.FS.writeFile( + `/working/${meshFile.name}`, + new Uint8Array(meshFile.content), + ); + console.log(meshFile.name); + } + + // Update scene dropdown + parentContext.allScenes[newSceneName] = xmlFile.name; + console.log(parentContext.allScenes); + if (parentContext.updateSceneDropdown) { + parentContext.updateSceneDropdown( + parentContext.sceneDropdown, + parentContext.allScenes, + ); + } + + parentContext.params.scene = xmlFile.name; + reload(); + + console.log( + `Uploaded ${xmlFile.name} and ${meshFiles.length} mesh file(s)`, + ); + } catch (error) { + console.error("Error auto-uploading URDF tar:", error); + } +} diff --git a/frontend/public/examples/scenes/generate_index.py b/frontend/public/examples/scenes/generate_index.py new file mode 100644 index 00000000..143ea7e6 --- /dev/null +++ b/frontend/public/examples/scenes/generate_index.py @@ -0,0 +1,33 @@ +"""Generate index.json for allowed files, ignoring specified extensions.""" + +import argparse +import json +from pathlib import Path +from typing import List + +_HERE = Path(__file__).parent + +_ALLOWED_EXTENSIONS = [".xml", ".png", ".stl", ".STL", ".obj"] + + +def main(ignored_extensions: List[str]) -> None: + files_to_download = [] + for path in _HERE.rglob("*"): + if path.is_file() and path.suffix in _ALLOWED_EXTENSIONS and path.suffix not in ignored_extensions: + files_to_download.append(str(path.relative_to(_HERE))) + files_to_download.sort() + index_file_path = _HERE / "index.json" + with open(index_file_path, mode="w") as f: + json.dump(files_to_download, f, indent=2) + + print(f"File written to: {index_file_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Generate index.json for allowed files, ignoring specified extensions." + ) + parser.add_argument("--ignore", nargs="*", default=[], help="List of file extensions to ignore") + args = parser.parse_args() + + main(args.ignore) diff --git a/frontend/public/examples/scenes/humanoid.xml b/frontend/public/examples/scenes/humanoid.xml new file mode 100644 index 00000000..1de0ac8b --- /dev/null +++ b/frontend/public/examples/scenes/humanoid.xml @@ -0,0 +1,249 @@ + + + + diff --git a/frontend/public/examples/scenes/index.json b/frontend/public/examples/scenes/index.json new file mode 100644 index 00000000..72af2351 --- /dev/null +++ b/frontend/public/examples/scenes/index.json @@ -0,0 +1,4 @@ +[ + "humanoid.xml", + "scene.xml" +] \ No newline at end of file diff --git a/frontend/public/examples/scenes/scene.xml b/frontend/public/examples/scenes/scene.xml new file mode 100644 index 00000000..16f62e9a --- /dev/null +++ b/frontend/public/examples/scenes/scene.xml @@ -0,0 +1,582 @@ + + + diff --git a/frontend/public/examples/scenes/stompypro.xml b/frontend/public/examples/scenes/stompypro.xml new file mode 100644 index 00000000..de1825ae --- /dev/null +++ b/frontend/public/examples/scenes/stompypro.xml @@ -0,0 +1,191 @@ + + + + + + + + + + + + diff --git a/frontend/public/examples/scenes/stompypro_meshes/buttock.stl b/frontend/public/examples/scenes/stompypro_meshes/buttock.stl new file mode 100644 index 00000000..987da2db Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/buttock.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/calf.stl b/frontend/public/examples/scenes/stompypro_meshes/calf.stl new file mode 100644 index 00000000..40d2451b Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/calf.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/clav.stl b/frontend/public/examples/scenes/stompypro_meshes/clav.stl new file mode 100644 index 00000000..abe462a5 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/clav.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/farm.stl b/frontend/public/examples/scenes/stompypro_meshes/farm.stl new file mode 100644 index 00000000..bcbcd7c5 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/farm.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/foot.stl b/frontend/public/examples/scenes/stompypro_meshes/foot.stl new file mode 100644 index 00000000..443e9e62 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/foot.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/leg.stl b/frontend/public/examples/scenes/stompypro_meshes/leg.stl new file mode 100644 index 00000000..ec182c10 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/leg.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/mcalf.stl b/frontend/public/examples/scenes/stompypro_meshes/mcalf.stl new file mode 100644 index 00000000..9e82b7da Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/mcalf.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/mfoot.stl b/frontend/public/examples/scenes/stompypro_meshes/mfoot.stl new file mode 100644 index 00000000..1ffc7bbe Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/mfoot.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/mthigh.stl b/frontend/public/examples/scenes/stompypro_meshes/mthigh.stl new file mode 100644 index 00000000..7d21077c Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/mthigh.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/scap.stl b/frontend/public/examples/scenes/stompypro_meshes/scap.stl new file mode 100644 index 00000000..c6388e78 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/scap.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/thigh.stl b/frontend/public/examples/scenes/stompypro_meshes/thigh.stl new file mode 100644 index 00000000..1647c483 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/thigh.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/trunk.stl b/frontend/public/examples/scenes/stompypro_meshes/trunk.stl new file mode 100644 index 00000000..adbc3136 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/trunk.stl differ diff --git a/frontend/public/examples/scenes/stompypro_meshes/uarm.stl b/frontend/public/examples/scenes/stompypro_meshes/uarm.stl new file mode 100644 index 00000000..dcd98229 Binary files /dev/null and b/frontend/public/examples/scenes/stompypro_meshes/uarm.stl differ diff --git a/frontend/public/examples/utils/Debug.js b/frontend/public/examples/utils/Debug.js new file mode 100644 index 00000000..5b19cd68 --- /dev/null +++ b/frontend/public/examples/utils/Debug.js @@ -0,0 +1,36 @@ +/** This class provides Debug Utilities. */ +class Debug { + + /** Reroute Console Errors to the Main Screen (for mobile) */ + constructor() { + // Intercept Main Window Errors as well + window.realConsoleError = console.error; + window.addEventListener('error', (event) => { + let path = event.filename.split("/"); + this.display((path[path.length - 1] + ":" + event.lineno + " - " + event.message)); + }); + console.error = this.fakeError.bind(this); + + // Record whether we're on Safari or Mobile (unused so far) + this.safari = /(Safari)/g.test( navigator.userAgent ) && ! /(Chrome)/g.test( navigator.userAgent ); + this.mobile = /(Android|iPad|iPhone|iPod|Oculus)/g.test(navigator.userAgent) || this.safari; + } + + // Log Errors as
s over the main viewport + fakeError(...args) { + if (args.length > 0 && args[0]) { this.display(JSON.stringify(args[0])); } + window.realConsoleError.apply(console, arguments); + } + + display(text) { + //if (this.mobile) { + let errorNode = window.document.getElementById("error"); + errorNode.innerHTML += "\n\n"+text.fontcolor("red"); + //window.document.getElementById("info").appendChild(errorNode); + //} + } + +} + +export { Debug }; +let debug = new Debug(); \ No newline at end of file diff --git a/frontend/public/examples/utils/DragStateManager.js b/frontend/public/examples/utils/DragStateManager.js new file mode 100644 index 00000000..84675035 --- /dev/null +++ b/frontend/public/examples/utils/DragStateManager.js @@ -0,0 +1,135 @@ +import * as THREE from 'three'; +import { Vector3 } from 'three'; + +export class DragStateManager { + constructor(scene, renderer, camera, container, controls) { + this.scene = scene; + this.renderer = renderer; + this.camera = camera; + this.mousePos = new THREE.Vector2(); + this.raycaster = new THREE.Raycaster(); + //this.raycaster.layers.set(1); + // this.raycaster.params.Mesh.threshold = 3; + this.raycaster.params.Line.threshold = 0.1; + this.grabDistance = 0.0; + this.active = false; + this.physicsObject = null; + this.controls = controls; + + this.arrow = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 15, 0x666666); + this.arrow.setLength(15, 3, 1); + this.scene.add(this.arrow); + //this.residuals.push(arrow); + this.arrow.line.material.transparent = true; + this.arrow.cone.material.transparent = true; + this.arrow.line.material.opacity = 0.5; + this.arrow.cone.material.opacity = 0.5; + this.arrow.visible = false; + + this.previouslySelected = null; + this.higlightColor = 0xff0000; // 0x777777 + + this.localHit = new Vector3(); + this.worldHit = new Vector3(); + this.currentWorld = new Vector3(); + + container.addEventListener( 'pointerdown', this.onPointer.bind(this), true ); + document.addEventListener( 'pointermove', this.onPointer.bind(this), true ); + document.addEventListener( 'pointerup' , this.onPointer.bind(this), true ); + document.addEventListener( 'pointerout' , this.onPointer.bind(this), true ); + container.addEventListener( 'dblclick', this.onPointer.bind(this), false ); + } + updateRaycaster(x, y) { + var rect = this.renderer.domElement.getBoundingClientRect(); + this.mousePos.x = ((x - rect.left) / rect.width) * 2 - 1; + this.mousePos.y = -((y - rect.top) / rect.height) * 2 + 1; + this.raycaster.setFromCamera(this.mousePos, this.camera); + } + start(x, y) { + this.physicsObject = null; + this.updateRaycaster(x, y); + let intersects = this.raycaster.intersectObjects(this.scene.children); + for (let i = 0; i < intersects.length; i++) { + let obj = intersects[i].object; + if (obj.bodyID && obj.bodyID > 0) { + this.physicsObject = obj; + this.grabDistance = intersects[0].distance; + let hit = this.raycaster.ray.origin.clone(); + hit.addScaledVector(this.raycaster.ray.direction, this.grabDistance); + this.arrow.position.copy(hit); + //this.physicsObject.startGrab(hit); + this.active = true; + this.controls.enabled = false; + this.localHit = obj.worldToLocal(hit.clone()); + this.worldHit.copy(hit); + this.currentWorld.copy(hit); + this.arrow.visible = true; + break; + } + } + } + move(x, y) { + if (this.active) { + this.updateRaycaster(x, y); + let hit = this.raycaster.ray.origin.clone(); + hit.addScaledVector(this.raycaster.ray.direction, this.grabDistance); + this.currentWorld.copy(hit); + + this.update(); + + if (this.physicsObject != null) { + //this.physicsObject.moveGrabbed(hit); + } + } + } + update() { + if (this.worldHit && this.localHit && this.currentWorld && this.arrow && this.physicsObject) { + this.worldHit.copy(this.localHit); + this.physicsObject.localToWorld(this.worldHit); + this.arrow.position.copy(this.worldHit); + this.arrow.setDirection(this.currentWorld.clone().sub(this.worldHit).normalize()); + this.arrow.setLength(this.currentWorld.clone().sub(this.worldHit).length()); + } + } + end(evt) { + //this.physicsObject.endGrab(); + this.physicsObject = null; + + this.active = false; + this.controls.enabled = true; + //this.controls.onPointerUp(evt); + this.arrow.visible = false; + this.mouseDown = false; + } + onPointer(evt) { + if (evt.type == "pointerdown") { + this.start(evt.clientX, evt.clientY); + this.mouseDown = true; + } else if (evt.type == "pointermove" && this.mouseDown) { + if (this.active) { this.move(evt.clientX, evt.clientY); } + } else if (evt.type == "pointerup" /*|| evt.type == "pointerout"*/) { + this.end(evt); + } + if (evt.type == "dblclick") { + this.start(evt.clientX, evt.clientY); + this.doubleClick = true; + if (this.physicsObject) { + if (this.physicsObject == this.previouslySelected) { + this.physicsObject.material.emissive.setHex(0x000000); + this.previouslySelected = null; + } else { + if (this.previouslySelected) { + this.previouslySelected.material.emissive.setHex(0x000000); + } + this.physicsObject.material.emissive.setHex(this.higlightColor); + this.previouslySelected = this.physicsObject; + } + } else { + if (this.previouslySelected) { + this.previouslySelected.material.emissive.setHex(0x000000); + this.previouslySelected = null; + } + } + } + } +} diff --git a/frontend/public/examples/utils/Reflector.js b/frontend/public/examples/utils/Reflector.js new file mode 100644 index 00000000..4a4a7ba4 --- /dev/null +++ b/frontend/public/examples/utils/Reflector.js @@ -0,0 +1,226 @@ +import { + Color, + Matrix4, + Mesh, + PerspectiveCamera, + Plane, + ShaderMaterial, + UniformsUtils, + Vector3, + Vector4, + WebGLRenderTarget, + HalfFloatType, + NoToneMapping, + LinearEncoding, + MeshPhysicalMaterial +} from 'three'; + +class Reflector extends Mesh { + + constructor( geometry, options = {} ) { + + super( geometry ); + + this.isReflector = true; + + this.type = 'Reflector'; + this.camera = new PerspectiveCamera(); + + const scope = this; + + const color = ( options.color !== undefined ) ? new Color( options.color ) : new Color( 0x7F7F7F ); + const textureWidth = options.textureWidth || 512; + const textureHeight = options.textureHeight || 512; + const clipBias = options.clipBias || 0; + const shader = options.shader || Reflector.ReflectorShader; + const multisample = ( options.multisample !== undefined ) ? options.multisample : 4; + const blendTexture = options.texture || undefined; + + // + + const reflectorPlane = new Plane(); + const normal = new Vector3(); + const reflectorWorldPosition = new Vector3(); + const cameraWorldPosition = new Vector3(); + const rotationMatrix = new Matrix4(); + const lookAtPosition = new Vector3( 0, 0, - 1 ); + const clipPlane = new Vector4(); + + const view = new Vector3(); + const target = new Vector3(); + const q = new Vector4(); + + const textureMatrix = new Matrix4(); + const virtualCamera = this.camera; + + const renderTarget = new WebGLRenderTarget( textureWidth, textureHeight, { samples: multisample, type: HalfFloatType } ); + + this.material = new MeshPhysicalMaterial( { map: blendTexture }); + this.material.uniforms = { tDiffuse : { value: renderTarget.texture }, + textureMatrix: { value: textureMatrix }}; + this.material.onBeforeCompile = ( shader ) => { + + // Vertex Shader: Set Vertex Positions to the Unwrapped UV Positions + let bodyStart = shader.vertexShader.indexOf( 'void main() {' ); + shader.vertexShader = + shader.vertexShader.slice(0, bodyStart) + + '\nuniform mat4 textureMatrix;\nvarying vec4 vUv3;\n' + + shader.vertexShader.slice( bodyStart - 1, - 1 ) + + ' vUv3 = textureMatrix * vec4( position, 1.0 ); }'; + + // Fragment Shader: Set Pixels to 9-tap box blur the current frame's Shadows + bodyStart = shader.fragmentShader.indexOf( 'void main() {' ); + shader.fragmentShader = + //'#define USE_UV\n' + + '\nuniform sampler2D tDiffuse; \n varying vec4 vUv3;\n' + + shader.fragmentShader.slice( 0, bodyStart ) + + shader.fragmentShader.slice( bodyStart - 1, - 1 ) + + ` gl_FragColor = vec4( mix( texture2DProj( tDiffuse, vUv3 ).rgb, gl_FragColor.rgb , 0.5), 1.0 ); + }`; + + // Set the LightMap Accumulation Buffer + shader.uniforms.tDiffuse = { value: renderTarget.texture }; + shader.uniforms.textureMatrix = { value: textureMatrix }; + this.material.uniforms = shader.uniforms; + + // Set the new Shader to this + this.material.userData.shader = shader; + }; + this.receiveShadow = true; + + + this.onBeforeRender = function ( renderer, scene, camera ) { + + reflectorWorldPosition.setFromMatrixPosition( scope.matrixWorld ); + cameraWorldPosition.setFromMatrixPosition( camera.matrixWorld ); + + rotationMatrix.extractRotation( scope.matrixWorld ); + + normal.set( 0, 0, 1 ); + normal.applyMatrix4( rotationMatrix ); + + view.subVectors( reflectorWorldPosition, cameraWorldPosition ); + + // Avoid rendering when reflector is facing away + + if ( view.dot( normal ) > 0 ) return; + + view.reflect( normal ).negate(); + view.add( reflectorWorldPosition ); + + rotationMatrix.extractRotation( camera.matrixWorld ); + + lookAtPosition.set( 0, 0, - 1 ); + lookAtPosition.applyMatrix4( rotationMatrix ); + lookAtPosition.add( cameraWorldPosition ); + + target.subVectors( reflectorWorldPosition, lookAtPosition ); + target.reflect( normal ).negate(); + target.add( reflectorWorldPosition ); + + virtualCamera.position.copy( view ); + virtualCamera.up.set( 0, 1, 0 ); + virtualCamera.up.applyMatrix4( rotationMatrix ); + virtualCamera.up.reflect( normal ); + virtualCamera.lookAt( target ); + + virtualCamera.far = camera.far; // Used in WebGLBackground + + virtualCamera.updateMatrixWorld(); + virtualCamera.projectionMatrix.copy( camera.projectionMatrix ); + + // Update the texture matrix + textureMatrix.set( + 0.5, 0.0, 0.0, 0.5, + 0.0, 0.5, 0.0, 0.5, + 0.0, 0.0, 0.5, 0.5, + 0.0, 0.0, 0.0, 1.0 + ); + textureMatrix.multiply( virtualCamera.projectionMatrix ); + textureMatrix.multiply( virtualCamera.matrixWorldInverse ); + textureMatrix.multiply( scope.matrixWorld ); + + // Now update projection matrix with new clip plane, implementing code from: http://www.terathon.com/code/oblique.html + // Paper explaining this technique: http://www.terathon.com/lengyel/Lengyel-Oblique.pdf + reflectorPlane.setFromNormalAndCoplanarPoint( normal, reflectorWorldPosition ); + reflectorPlane.applyMatrix4( virtualCamera.matrixWorldInverse ); + + clipPlane.set( reflectorPlane.normal.x, reflectorPlane.normal.y, reflectorPlane.normal.z, reflectorPlane.constant ); + + const projectionMatrix = virtualCamera.projectionMatrix; + + q.x = ( Math.sign( clipPlane.x ) + projectionMatrix.elements[ 8 ] ) / projectionMatrix.elements[ 0 ]; + q.y = ( Math.sign( clipPlane.y ) + projectionMatrix.elements[ 9 ] ) / projectionMatrix.elements[ 5 ]; + q.z = - 1.0; + q.w = ( 1.0 + projectionMatrix.elements[ 10 ] ) / projectionMatrix.elements[ 14 ]; + + // Calculate the scaled plane vector + clipPlane.multiplyScalar( 2.0 / clipPlane.dot( q ) ); + + // Replacing the third row of the projection matrix + projectionMatrix.elements[ 2 ] = clipPlane.x; + projectionMatrix.elements[ 6 ] = clipPlane.y; + projectionMatrix.elements[ 10 ] = clipPlane.z + 1.0 - clipBias; + projectionMatrix.elements[ 14 ] = clipPlane.w; + + // Render + scope.visible = false; + + const currentRenderTarget = renderer.getRenderTarget(); + + const currentXrEnabled = renderer.xr.enabled; + const currentShadowAutoUpdate = renderer.shadowMap.autoUpdate; + const currentOutputEncoding = renderer.outputEncoding; + const currentToneMapping = renderer.toneMapping; + + renderer.xr.enabled = false; // Avoid camera modification + renderer.shadowMap.autoUpdate = false; // Avoid re-computing shadows + renderer.outputEncoding = LinearEncoding; + renderer.toneMapping = NoToneMapping; + + renderer.setRenderTarget( renderTarget ); + + renderer.state.buffers.depth.setMask( true ); // make sure the depth buffer is writable so it can be properly cleared, see #18897 + + if ( renderer.autoClear === false ) renderer.clear(); + renderer.render( scene, virtualCamera ); + + renderer.xr.enabled = currentXrEnabled; + renderer.shadowMap.autoUpdate = currentShadowAutoUpdate; + renderer.outputEncoding = currentOutputEncoding; + renderer.toneMapping = currentToneMapping; + + renderer.setRenderTarget( currentRenderTarget ); + + // Restore viewport + + const viewport = camera.viewport; + + if ( viewport !== undefined ) { + + renderer.state.viewport( viewport ); + + } + + scope.visible = true; + + }; + + this.getRenderTarget = function () { + + return renderTarget; + + }; + + this.dispose = function () { + + renderTarget.dispose(); + scope.material.dispose(); + + }; + + } + +} + +export { Reflector }; diff --git a/frontend/public/node_modules/three/examples/jsm/controls/DragControls.js b/frontend/public/node_modules/three/examples/jsm/controls/DragControls.js new file mode 100644 index 00000000..4db48132 --- /dev/null +++ b/frontend/public/node_modules/three/examples/jsm/controls/DragControls.js @@ -0,0 +1,220 @@ +import { + EventDispatcher, + Matrix4, + Plane, + Raycaster, + Vector2, + Vector3 +} from 'three'; + +const _plane = new Plane(); +const _raycaster = new Raycaster(); + +const _pointer = new Vector2(); +const _offset = new Vector3(); +const _intersection = new Vector3(); +const _worldPosition = new Vector3(); +const _inverseMatrix = new Matrix4(); + +class DragControls extends EventDispatcher { + + constructor( _objects, _camera, _domElement ) { + + super(); + + _domElement.style.touchAction = 'none'; // disable touch scroll + + let _selected = null, _hovered = null; + + const _intersections = []; + + // + + const scope = this; + + function activate() { + + _domElement.addEventListener( 'pointermove', onPointerMove ); + _domElement.addEventListener( 'pointerdown', onPointerDown ); + _domElement.addEventListener( 'pointerup', onPointerCancel ); + _domElement.addEventListener( 'pointerleave', onPointerCancel ); + + } + + function deactivate() { + + _domElement.removeEventListener( 'pointermove', onPointerMove ); + _domElement.removeEventListener( 'pointerdown', onPointerDown ); + _domElement.removeEventListener( 'pointerup', onPointerCancel ); + _domElement.removeEventListener( 'pointerleave', onPointerCancel ); + + _domElement.style.cursor = ''; + + } + + function dispose() { + + deactivate(); + + } + + function getObjects() { + + return _objects; + + } + + function getRaycaster() { + + return _raycaster; + + } + + function onPointerMove( event ) { + + if ( scope.enabled === false ) return; + + updatePointer( event ); + + _raycaster.setFromCamera( _pointer, _camera ); + + if ( _selected ) { + + if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) { + + _selected.position.copy( _intersection.sub( _offset ).applyMatrix4( _inverseMatrix ) ); + + } + + scope.dispatchEvent( { type: 'drag', object: _selected } ); + + return; + + } + + // hover support + + if ( event.pointerType === 'mouse' || event.pointerType === 'pen' ) { + + _intersections.length = 0; + + _raycaster.setFromCamera( _pointer, _camera ); + _raycaster.intersectObjects( _objects, true, _intersections ); + + if ( _intersections.length > 0 ) { + + const object = _intersections[ 0 ].object; + + _plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( object.matrixWorld ) ); + + if ( _hovered !== object && _hovered !== null ) { + + scope.dispatchEvent( { type: 'hoveroff', object: _hovered } ); + + _domElement.style.cursor = 'auto'; + _hovered = null; + + } + + if ( _hovered !== object ) { + + scope.dispatchEvent( { type: 'hoveron', object: object } ); + + _domElement.style.cursor = 'pointer'; + _hovered = object; + + } + + } else { + + if ( _hovered !== null ) { + + scope.dispatchEvent( { type: 'hoveroff', object: _hovered } ); + + _domElement.style.cursor = 'auto'; + _hovered = null; + + } + + } + + } + + } + + function onPointerDown( event ) { + + if ( scope.enabled === false ) return; + + updatePointer( event ); + + _intersections.length = 0; + + _raycaster.setFromCamera( _pointer, _camera ); + _raycaster.intersectObjects( _objects, true, _intersections ); + + if ( _intersections.length > 0 ) { + + _selected = ( scope.transformGroup === true ) ? _objects[ 0 ] : _intersections[ 0 ].object; + + _plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) ); + + if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) { + + _inverseMatrix.copy( _selected.parent.matrixWorld ).invert(); + _offset.copy( _intersection ).sub( _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) ); + + } + + _domElement.style.cursor = 'move'; + + scope.dispatchEvent( { type: 'dragstart', object: _selected } ); + + } + + + } + + function onPointerCancel() { + + if ( scope.enabled === false ) return; + + if ( _selected ) { + + scope.dispatchEvent( { type: 'dragend', object: _selected } ); + + _selected = null; + + } + + _domElement.style.cursor = _hovered ? 'pointer' : 'auto'; + + } + + function updatePointer( event ) { + + const rect = _domElement.getBoundingClientRect(); + + _pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1; + _pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1; + + } + + activate(); + + // API + + this.enabled = true; + this.transformGroup = false; + + this.activate = activate; + this.deactivate = deactivate; + this.dispose = dispose; + this.getObjects = getObjects; + this.getRaycaster = getRaycaster; + + } + +} + +export { DragControls }; diff --git a/frontend/public/node_modules/three/examples/jsm/controls/OrbitControls.js b/frontend/public/node_modules/three/examples/jsm/controls/OrbitControls.js new file mode 100644 index 00000000..16169dcc --- /dev/null +++ b/frontend/public/node_modules/three/examples/jsm/controls/OrbitControls.js @@ -0,0 +1,1295 @@ +import { + EventDispatcher, + MOUSE, + Quaternion, + Spherical, + TOUCH, + Vector2, + Vector3 +} from 'three'; + +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). +// +// Orbit - left mouse / touch: one-finger move +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move + +const _changeEvent = { type: 'change' }; +const _startEvent = { type: 'start' }; +const _endEvent = { type: 'end' }; + +class OrbitControls extends EventDispatcher { + + constructor( object, domElement ) { + + super(); + + this.object = object; + this.domElement = domElement; + this.domElement.style.touchAction = 'none'; // disable touch scroll + + // Set to false to disable this control + this.enabled = true; + + // "target" sets the location of focus, where the object orbits around + this.target = new Vector3(); + + // How far you can dolly in and out ( PerspectiveCamera only ) + this.minDistance = 0; + this.maxDistance = Infinity; + + // How far you can zoom in and out ( OrthographicCamera only ) + this.minZoom = 0; + this.maxZoom = Infinity; + + // How far you can orbit vertically, upper and lower limits. + // Range is 0 to Math.PI radians. + this.minPolarAngle = 0; // radians + this.maxPolarAngle = Math.PI; // radians + + // How far you can orbit horizontally, upper and lower limits. + // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) + this.minAzimuthAngle = - Infinity; // radians + this.maxAzimuthAngle = Infinity; // radians + + // Set to true to enable damping (inertia) + // If damping is enabled, you must call controls.update() in your animation loop + this.enableDamping = false; + this.dampingFactor = 0.05; + + // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. + // Set to false to disable zooming + this.enableZoom = true; + this.zoomSpeed = 1.0; + + // Set to false to disable rotating + this.enableRotate = true; + this.rotateSpeed = 1.0; + + // Set to false to disable panning + this.enablePan = true; + this.panSpeed = 1.0; + this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up + this.keyPanSpeed = 7.0; // pixels moved per arrow key push + + // Set to true to automatically rotate around the target + // If auto-rotate is enabled, you must call controls.update() in your animation loop + this.autoRotate = false; + this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60 + + // The four arrow keys + this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' }; + + // Mouse buttons + this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }; + + // Touch fingers + this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }; + + // for reset + this.target0 = this.target.clone(); + this.position0 = this.object.position.clone(); + this.zoom0 = this.object.zoom; + + // the target DOM element for key events + this._domElementKeyEvents = null; + + // + // public methods + // + + this.getPolarAngle = function () { + + return spherical.phi; + + }; + + this.getAzimuthalAngle = function () { + + return spherical.theta; + + }; + + this.getDistance = function () { + + return this.object.position.distanceTo( this.target ); + + }; + + this.listenToKeyEvents = function ( domElement ) { + + domElement.addEventListener( 'keydown', onKeyDown ); + this._domElementKeyEvents = domElement; + + }; + + this.stopListenToKeyEvents = function () { + + this._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown ); + this._domElementKeyEvents = null; + + }; + + this.saveState = function () { + + scope.target0.copy( scope.target ); + scope.position0.copy( scope.object.position ); + scope.zoom0 = scope.object.zoom; + + }; + + this.reset = function () { + + scope.target.copy( scope.target0 ); + scope.object.position.copy( scope.position0 ); + scope.object.zoom = scope.zoom0; + + scope.object.updateProjectionMatrix(); + scope.dispatchEvent( _changeEvent ); + + scope.update(); + + state = STATE.NONE; + + }; + + // this method is exposed, but perhaps it would be better if we can make it private... + this.update = function () { + + const offset = new Vector3(); + + // so camera.up is the orbit axis + const quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) ); + const quatInverse = quat.clone().invert(); + + const lastPosition = new Vector3(); + const lastQuaternion = new Quaternion(); + + const twoPI = 2 * Math.PI; + + return function update() { + + const position = scope.object.position; + + offset.copy( position ).sub( scope.target ); + + // rotate offset to "y-axis-is-up" space + offset.applyQuaternion( quat ); + + // angle from z-axis around y-axis + spherical.setFromVector3( offset ); + + if ( scope.autoRotate && state === STATE.NONE ) { + + rotateLeft( getAutoRotationAngle() ); + + } + + if ( scope.enableDamping ) { + + spherical.theta += sphericalDelta.theta * scope.dampingFactor; + spherical.phi += sphericalDelta.phi * scope.dampingFactor; + + } else { + + spherical.theta += sphericalDelta.theta; + spherical.phi += sphericalDelta.phi; + + } + + // restrict theta to be between desired limits + + let min = scope.minAzimuthAngle; + let max = scope.maxAzimuthAngle; + + if ( isFinite( min ) && isFinite( max ) ) { + + if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI; + + if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI; + + if ( min <= max ) { + + spherical.theta = Math.max( min, Math.min( max, spherical.theta ) ); + + } else { + + spherical.theta = ( spherical.theta > ( min + max ) / 2 ) ? + Math.max( min, spherical.theta ) : + Math.min( max, spherical.theta ); + + } + + } + + // restrict phi to be between desired limits + spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) ); + + spherical.makeSafe(); + + + spherical.radius *= scale; + + // restrict radius to be between desired limits + spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) ); + + // move target to panned location + + if ( scope.enableDamping === true ) { + + scope.target.addScaledVector( panOffset, scope.dampingFactor ); + + } else { + + scope.target.add( panOffset ); + + } + + offset.setFromSpherical( spherical ); + + // rotate offset back to "camera-up-vector-is-up" space + offset.applyQuaternion( quatInverse ); + + position.copy( scope.target ).add( offset ); + + scope.object.lookAt( scope.target ); + + if ( scope.enableDamping === true ) { + + sphericalDelta.theta *= ( 1 - scope.dampingFactor ); + sphericalDelta.phi *= ( 1 - scope.dampingFactor ); + + panOffset.multiplyScalar( 1 - scope.dampingFactor ); + + } else { + + sphericalDelta.set( 0, 0, 0 ); + + panOffset.set( 0, 0, 0 ); + + } + + scale = 1; + + // update condition is: + // min(camera displacement, camera rotation in radians)^2 > EPS + // using small-angle approximation cos(x/2) = 1 - x^2 / 8 + + if ( zoomChanged || + lastPosition.distanceToSquared( scope.object.position ) > EPS || + 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) { + + scope.dispatchEvent( _changeEvent ); + + lastPosition.copy( scope.object.position ); + lastQuaternion.copy( scope.object.quaternion ); + zoomChanged = false; + + return true; + + } + + return false; + + }; + + }(); + + this.dispose = function () { + + scope.domElement.removeEventListener( 'contextmenu', onContextMenu ); + + scope.domElement.removeEventListener( 'pointerdown', onPointerDown ); + scope.domElement.removeEventListener( 'pointercancel', onPointerCancel ); + scope.domElement.removeEventListener( 'wheel', onMouseWheel ); + + scope.domElement.removeEventListener( 'pointermove', onPointerMove ); + scope.domElement.removeEventListener( 'pointerup', onPointerUp ); + + + if ( scope._domElementKeyEvents !== null ) { + + scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown ); + scope._domElementKeyEvents = null; + + } + + //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? + + }; + + // + // internals + // + + const scope = this; + + const STATE = { + NONE: - 1, + ROTATE: 0, + DOLLY: 1, + PAN: 2, + TOUCH_ROTATE: 3, + TOUCH_PAN: 4, + TOUCH_DOLLY_PAN: 5, + TOUCH_DOLLY_ROTATE: 6 + }; + + let state = STATE.NONE; + + const EPS = 0.000001; + + // current position in spherical coordinates + const spherical = new Spherical(); + const sphericalDelta = new Spherical(); + + let scale = 1; + const panOffset = new Vector3(); + let zoomChanged = false; + + const rotateStart = new Vector2(); + const rotateEnd = new Vector2(); + const rotateDelta = new Vector2(); + + const panStart = new Vector2(); + const panEnd = new Vector2(); + const panDelta = new Vector2(); + + const dollyStart = new Vector2(); + const dollyEnd = new Vector2(); + const dollyDelta = new Vector2(); + + const pointers = []; + const pointerPositions = {}; + + function getAutoRotationAngle() { + + return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; + + } + + function getZoomScale() { + + return Math.pow( 0.95, scope.zoomSpeed ); + + } + + function rotateLeft( angle ) { + + sphericalDelta.theta -= angle; + + } + + function rotateUp( angle ) { + + sphericalDelta.phi -= angle; + + } + + const panLeft = function () { + + const v = new Vector3(); + + return function panLeft( distance, objectMatrix ) { + + v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix + v.multiplyScalar( - distance ); + + panOffset.add( v ); + + }; + + }(); + + const panUp = function () { + + const v = new Vector3(); + + return function panUp( distance, objectMatrix ) { + + if ( scope.screenSpacePanning === true ) { + + v.setFromMatrixColumn( objectMatrix, 1 ); + + } else { + + v.setFromMatrixColumn( objectMatrix, 0 ); + v.crossVectors( scope.object.up, v ); + + } + + v.multiplyScalar( distance ); + + panOffset.add( v ); + + }; + + }(); + + // deltaX and deltaY are in pixels; right and down are positive + const pan = function () { + + const offset = new Vector3(); + + return function pan( deltaX, deltaY ) { + + const element = scope.domElement; + + if ( scope.object.isPerspectiveCamera ) { + + // perspective + const position = scope.object.position; + offset.copy( position ).sub( scope.target ); + let targetDistance = offset.length(); + + // half of the fov is center to top of screen + targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 ); + + // we use only clientHeight here so aspect ratio does not distort speed + panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix ); + panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix ); + + } else if ( scope.object.isOrthographicCamera ) { + + // orthographic + panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix ); + panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix ); + + } else { + + // camera neither orthographic nor perspective + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' ); + scope.enablePan = false; + + } + + }; + + }(); + + function dollyOut( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale /= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } + + function dollyIn( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale *= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } + + // + // event callbacks - update the object state + // + + function handleMouseDownRotate( event ) { + + rotateStart.set( event.clientX, event.clientY ); + + } + + function handleMouseDownDolly( event ) { + + dollyStart.set( event.clientX, event.clientY ); + + } + + function handleMouseDownPan( event ) { + + panStart.set( event.clientX, event.clientY ); + + } + + function handleMouseMoveRotate( event ) { + + rotateEnd.set( event.clientX, event.clientY ); + + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + + const element = scope.domElement; + + rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + + rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); + + rotateStart.copy( rotateEnd ); + + scope.update(); + + } + + function handleMouseMoveDolly( event ) { + + dollyEnd.set( event.clientX, event.clientY ); + + dollyDelta.subVectors( dollyEnd, dollyStart ); + + if ( dollyDelta.y > 0 ) { + + dollyOut( getZoomScale() ); + + } else if ( dollyDelta.y < 0 ) { + + dollyIn( getZoomScale() ); + + } + + dollyStart.copy( dollyEnd ); + + scope.update(); + + } + + function handleMouseMovePan( event ) { + + panEnd.set( event.clientX, event.clientY ); + + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); + + pan( panDelta.x, panDelta.y ); + + panStart.copy( panEnd ); + + scope.update(); + + } + + function handleMouseWheel( event ) { + + if ( event.deltaY < 0 ) { + + dollyIn( getZoomScale() ); + + } else if ( event.deltaY > 0 ) { + + dollyOut( getZoomScale() ); + + } + + scope.update(); + + } + + function handleKeyDown( event ) { + + let needsUpdate = false; + + switch ( event.code ) { + + case scope.keys.UP: + + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + rotateUp( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); + + } else { + + pan( 0, scope.keyPanSpeed ); + + } + + needsUpdate = true; + break; + + case scope.keys.BOTTOM: + + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + rotateUp( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); + + } else { + + pan( 0, - scope.keyPanSpeed ); + + } + + needsUpdate = true; + break; + + case scope.keys.LEFT: + + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + rotateLeft( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); + + } else { + + pan( scope.keyPanSpeed, 0 ); + + } + + needsUpdate = true; + break; + + case scope.keys.RIGHT: + + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + rotateLeft( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); + + } else { + + pan( - scope.keyPanSpeed, 0 ); + + } + + needsUpdate = true; + break; + + } + + if ( needsUpdate ) { + + // prevent the browser from scrolling on cursor keys + event.preventDefault(); + + scope.update(); + + } + + + } + + function handleTouchStartRotate() { + + if ( pointers.length === 1 ) { + + rotateStart.set( pointers[ 0 ].pageX, pointers[ 0 ].pageY ); + + } else { + + const x = 0.5 * ( pointers[ 0 ].pageX + pointers[ 1 ].pageX ); + const y = 0.5 * ( pointers[ 0 ].pageY + pointers[ 1 ].pageY ); + + rotateStart.set( x, y ); + + } + + } + + function handleTouchStartPan() { + + if ( pointers.length === 1 ) { + + panStart.set( pointers[ 0 ].pageX, pointers[ 0 ].pageY ); + + } else { + + const x = 0.5 * ( pointers[ 0 ].pageX + pointers[ 1 ].pageX ); + const y = 0.5 * ( pointers[ 0 ].pageY + pointers[ 1 ].pageY ); + + panStart.set( x, y ); + + } + + } + + function handleTouchStartDolly() { + + const dx = pointers[ 0 ].pageX - pointers[ 1 ].pageX; + const dy = pointers[ 0 ].pageY - pointers[ 1 ].pageY; + + const distance = Math.sqrt( dx * dx + dy * dy ); + + dollyStart.set( 0, distance ); + + } + + function handleTouchStartDollyPan() { + + if ( scope.enableZoom ) handleTouchStartDolly(); + + if ( scope.enablePan ) handleTouchStartPan(); + + } + + function handleTouchStartDollyRotate() { + + if ( scope.enableZoom ) handleTouchStartDolly(); + + if ( scope.enableRotate ) handleTouchStartRotate(); + + } + + function handleTouchMoveRotate( event ) { + + if ( pointers.length == 1 ) { + + rotateEnd.set( event.pageX, event.pageY ); + + } else { + + const position = getSecondPointerPosition( event ); + + const x = 0.5 * ( event.pageX + position.x ); + const y = 0.5 * ( event.pageY + position.y ); + + rotateEnd.set( x, y ); + + } + + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + + const element = scope.domElement; + + rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + + rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); + + rotateStart.copy( rotateEnd ); + + } + + function handleTouchMovePan( event ) { + + if ( pointers.length === 1 ) { + + panEnd.set( event.pageX, event.pageY ); + + } else { + + const position = getSecondPointerPosition( event ); + + const x = 0.5 * ( event.pageX + position.x ); + const y = 0.5 * ( event.pageY + position.y ); + + panEnd.set( x, y ); + + } + + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); + + pan( panDelta.x, panDelta.y ); + + panStart.copy( panEnd ); + + } + + function handleTouchMoveDolly( event ) { + + const position = getSecondPointerPosition( event ); + + const dx = event.pageX - position.x; + const dy = event.pageY - position.y; + + const distance = Math.sqrt( dx * dx + dy * dy ); + + dollyEnd.set( 0, distance ); + + dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) ); + + dollyOut( dollyDelta.y ); + + dollyStart.copy( dollyEnd ); + + } + + function handleTouchMoveDollyPan( event ) { + + if ( scope.enableZoom ) handleTouchMoveDolly( event ); + + if ( scope.enablePan ) handleTouchMovePan( event ); + + } + + function handleTouchMoveDollyRotate( event ) { + + if ( scope.enableZoom ) handleTouchMoveDolly( event ); + + if ( scope.enableRotate ) handleTouchMoveRotate( event ); + + } + + // + // event handlers - FSM: listen for events and reset state + // + + function onPointerDown( event ) { + + if ( scope.enabled === false ) return; + + if ( pointers.length === 0 ) { + + scope.domElement.setPointerCapture( event.pointerId ); + + scope.domElement.addEventListener( 'pointermove', onPointerMove ); + scope.domElement.addEventListener( 'pointerup', onPointerUp ); + + } + + // + + addPointer( event ); + + if ( event.pointerType === 'touch' ) { + + onTouchStart( event ); + + } else { + + onMouseDown( event ); + + } + + } + + function onPointerMove( event ) { + + if ( scope.enabled === false ) return; + + if ( event.pointerType === 'touch' ) { + + onTouchMove( event ); + + } else { + + onMouseMove( event ); + + } + + } + + function onPointerUp( event ) { + + removePointer( event ); + + if ( pointers.length === 0 ) { + + scope.domElement.releasePointerCapture( event.pointerId ); + + scope.domElement.removeEventListener( 'pointermove', onPointerMove ); + scope.domElement.removeEventListener( 'pointerup', onPointerUp ); + + } + + scope.dispatchEvent( _endEvent ); + + state = STATE.NONE; + + } + + function onPointerCancel( event ) { + + removePointer( event ); + + } + + function onMouseDown( event ) { + + let mouseAction; + + switch ( event.button ) { + + case 0: + + mouseAction = scope.mouseButtons.LEFT; + break; + + case 1: + + mouseAction = scope.mouseButtons.MIDDLE; + break; + + case 2: + + mouseAction = scope.mouseButtons.RIGHT; + break; + + default: + + mouseAction = - 1; + + } + + switch ( mouseAction ) { + + case MOUSE.DOLLY: + + if ( scope.enableZoom === false ) return; + + handleMouseDownDolly( event ); + + state = STATE.DOLLY; + + break; + + case MOUSE.ROTATE: + + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + if ( scope.enablePan === false ) return; + + handleMouseDownPan( event ); + + state = STATE.PAN; + + } else { + + if ( scope.enableRotate === false ) return; + + handleMouseDownRotate( event ); + + state = STATE.ROTATE; + + } + + break; + + case MOUSE.PAN: + + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + if ( scope.enableRotate === false ) return; + + handleMouseDownRotate( event ); + + state = STATE.ROTATE; + + } else { + + if ( scope.enablePan === false ) return; + + handleMouseDownPan( event ); + + state = STATE.PAN; + + } + + break; + + default: + + state = STATE.NONE; + + } + + if ( state !== STATE.NONE ) { + + scope.dispatchEvent( _startEvent ); + + } + + } + + function onMouseMove( event ) { + + switch ( state ) { + + case STATE.ROTATE: + + if ( scope.enableRotate === false ) return; + + handleMouseMoveRotate( event ); + + break; + + case STATE.DOLLY: + + if ( scope.enableZoom === false ) return; + + handleMouseMoveDolly( event ); + + break; + + case STATE.PAN: + + if ( scope.enablePan === false ) return; + + handleMouseMovePan( event ); + + break; + + } + + } + + function onMouseWheel( event ) { + + if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE ) return; + + event.preventDefault(); + + scope.dispatchEvent( _startEvent ); + + handleMouseWheel( event ); + + scope.dispatchEvent( _endEvent ); + + } + + function onKeyDown( event ) { + + if ( scope.enabled === false || scope.enablePan === false ) return; + + handleKeyDown( event ); + + } + + function onTouchStart( event ) { + + trackPointer( event ); + + switch ( pointers.length ) { + + case 1: + + switch ( scope.touches.ONE ) { + + case TOUCH.ROTATE: + + if ( scope.enableRotate === false ) return; + + handleTouchStartRotate(); + + state = STATE.TOUCH_ROTATE; + + break; + + case TOUCH.PAN: + + if ( scope.enablePan === false ) return; + + handleTouchStartPan(); + + state = STATE.TOUCH_PAN; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + case 2: + + switch ( scope.touches.TWO ) { + + case TOUCH.DOLLY_PAN: + + if ( scope.enableZoom === false && scope.enablePan === false ) return; + + handleTouchStartDollyPan(); + + state = STATE.TOUCH_DOLLY_PAN; + + break; + + case TOUCH.DOLLY_ROTATE: + + if ( scope.enableZoom === false && scope.enableRotate === false ) return; + + handleTouchStartDollyRotate(); + + state = STATE.TOUCH_DOLLY_ROTATE; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + default: + + state = STATE.NONE; + + } + + if ( state !== STATE.NONE ) { + + scope.dispatchEvent( _startEvent ); + + } + + } + + function onTouchMove( event ) { + + trackPointer( event ); + + switch ( state ) { + + case STATE.TOUCH_ROTATE: + + if ( scope.enableRotate === false ) return; + + handleTouchMoveRotate( event ); + + scope.update(); + + break; + + case STATE.TOUCH_PAN: + + if ( scope.enablePan === false ) return; + + handleTouchMovePan( event ); + + scope.update(); + + break; + + case STATE.TOUCH_DOLLY_PAN: + + if ( scope.enableZoom === false && scope.enablePan === false ) return; + + handleTouchMoveDollyPan( event ); + + scope.update(); + + break; + + case STATE.TOUCH_DOLLY_ROTATE: + + if ( scope.enableZoom === false && scope.enableRotate === false ) return; + + handleTouchMoveDollyRotate( event ); + + scope.update(); + + break; + + default: + + state = STATE.NONE; + + } + + } + + function onContextMenu( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + } + + function addPointer( event ) { + + pointers.push( event ); + + } + + function removePointer( event ) { + + delete pointerPositions[ event.pointerId ]; + + for ( let i = 0; i < pointers.length; i ++ ) { + + if ( pointers[ i ].pointerId == event.pointerId ) { + + pointers.splice( i, 1 ); + return; + + } + + } + + } + + function trackPointer( event ) { + + let position = pointerPositions[ event.pointerId ]; + + if ( position === undefined ) { + + position = new Vector2(); + pointerPositions[ event.pointerId ] = position; + + } + + position.set( event.pageX, event.pageY ); + + } + + function getSecondPointerPosition( event ) { + + const pointer = ( event.pointerId === pointers[ 0 ].pointerId ) ? pointers[ 1 ] : pointers[ 0 ]; + + return pointerPositions[ pointer.pointerId ]; + + } + + // + + scope.domElement.addEventListener( 'contextmenu', onContextMenu ); + + scope.domElement.addEventListener( 'pointerdown', onPointerDown ); + scope.domElement.addEventListener( 'pointercancel', onPointerCancel ); + scope.domElement.addEventListener( 'wheel', onMouseWheel, { passive: false } ); + + // force an update at start + + this.update(); + + } + +} + + +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). +// This is very similar to OrbitControls, another set of touch behavior +// +// Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - left mouse, or arrow keys / touch: one-finger move + +class MapControls extends OrbitControls { + + constructor( object, domElement ) { + + super( object, domElement ); + + this.screenSpacePanning = false; // pan orthogonal to world-space direction camera.up + + this.mouseButtons.LEFT = MOUSE.PAN; + this.mouseButtons.RIGHT = MOUSE.ROTATE; + + this.touches.ONE = TOUCH.PAN; + this.touches.TWO = TOUCH.DOLLY_ROTATE; + + } + +} + +export { OrbitControls, MapControls }; diff --git a/frontend/public/node_modules/three/examples/jsm/libs/lil-gui.module.min.js b/frontend/public/node_modules/three/examples/jsm/libs/lil-gui.module.min.js new file mode 100644 index 00000000..c76a5ef8 --- /dev/null +++ b/frontend/public/node_modules/three/examples/jsm/libs/lil-gui.module.min.js @@ -0,0 +1,8 @@ +/** + * lil-gui + * https://lil-gui.georgealways.com + * @version 0.17.0 + * @author George Michael Brower + * @license MIT + */ +class t{constructor(i,e,s,n,l="div"){this.parent=i,this.object=e,this.property=s,this._disabled=!1,this._hidden=!1,this.initialValue=this.getValue(),this.domElement=document.createElement("div"),this.domElement.classList.add("controller"),this.domElement.classList.add(n),this.$name=document.createElement("div"),this.$name.classList.add("name"),t.nextNameID=t.nextNameID||0,this.$name.id="lil-gui-name-"+ ++t.nextNameID,this.$widget=document.createElement(l),this.$widget.classList.add("widget"),this.$disable=this.$widget,this.domElement.appendChild(this.$name),this.domElement.appendChild(this.$widget),this.parent.children.push(this),this.parent.controllers.push(this),this.parent.$children.appendChild(this.domElement),this._listenCallback=this._listenCallback.bind(this),this.name(s)}name(t){return this._name=t,this.$name.innerHTML=t,this}onChange(t){return this._onChange=t,this}_callOnChange(){this.parent._callOnChange(this),void 0!==this._onChange&&this._onChange.call(this,this.getValue()),this._changed=!0}onFinishChange(t){return this._onFinishChange=t,this}_callOnFinishChange(){this._changed&&(this.parent._callOnFinishChange(this),void 0!==this._onFinishChange&&this._onFinishChange.call(this,this.getValue())),this._changed=!1}reset(){return this.setValue(this.initialValue),this._callOnFinishChange(),this}enable(t=!0){return this.disable(!t)}disable(t=!0){return t===this._disabled||(this._disabled=t,this.domElement.classList.toggle("disabled",t),this.$disable.toggleAttribute("disabled",t)),this}show(t=!0){return this._hidden=!t,this.domElement.style.display=this._hidden?"none":"",this}hide(){return this.show(!1)}options(t){const i=this.parent.add(this.object,this.property,t);return i.name(this._name),this.destroy(),i}min(t){return this}max(t){return this}step(t){return this}decimals(t){return this}listen(t=!0){return this._listening=t,void 0!==this._listenCallbackID&&(cancelAnimationFrame(this._listenCallbackID),this._listenCallbackID=void 0),this._listening&&this._listenCallback(),this}_listenCallback(){this._listenCallbackID=requestAnimationFrame(this._listenCallback);const t=this.save();t!==this._listenPrevValue&&this.updateDisplay(),this._listenPrevValue=t}getValue(){return this.object[this.property]}setValue(t){return this.object[this.property]=t,this._callOnChange(),this.updateDisplay(),this}updateDisplay(){return this}load(t){return this.setValue(t),this._callOnFinishChange(),this}save(){return this.getValue()}destroy(){this.listen(!1),this.parent.children.splice(this.parent.children.indexOf(this),1),this.parent.controllers.splice(this.parent.controllers.indexOf(this),1),this.parent.$children.removeChild(this.domElement)}}class i extends t{constructor(t,i,e){super(t,i,e,"boolean","label"),this.$input=document.createElement("input"),this.$input.setAttribute("type","checkbox"),this.$input.setAttribute("aria-labelledby",this.$name.id),this.$widget.appendChild(this.$input),this.$input.addEventListener("change",()=>{this.setValue(this.$input.checked),this._callOnFinishChange()}),this.$disable=this.$input,this.updateDisplay()}updateDisplay(){return this.$input.checked=this.getValue(),this}}function e(t){let i,e;return(i=t.match(/(#|0x)?([a-f0-9]{6})/i))?e=i[2]:(i=t.match(/rgb\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*\)/))?e=parseInt(i[1]).toString(16).padStart(2,0)+parseInt(i[2]).toString(16).padStart(2,0)+parseInt(i[3]).toString(16).padStart(2,0):(i=t.match(/^#?([a-f0-9])([a-f0-9])([a-f0-9])$/i))&&(e=i[1]+i[1]+i[2]+i[2]+i[3]+i[3]),!!e&&"#"+e}const s={isPrimitive:!0,match:t=>"string"==typeof t,fromHexString:e,toHexString:e},n={isPrimitive:!0,match:t=>"number"==typeof t,fromHexString:t=>parseInt(t.substring(1),16),toHexString:t=>"#"+t.toString(16).padStart(6,0)},l={isPrimitive:!1,match:Array.isArray,fromHexString(t,i,e=1){const s=n.fromHexString(t);i[0]=(s>>16&255)/255*e,i[1]=(s>>8&255)/255*e,i[2]=(255&s)/255*e},toHexString:([t,i,e],s=1)=>n.toHexString(t*(s=255/s)<<16^i*s<<8^e*s<<0)},r={isPrimitive:!1,match:t=>Object(t)===t,fromHexString(t,i,e=1){const s=n.fromHexString(t);i.r=(s>>16&255)/255*e,i.g=(s>>8&255)/255*e,i.b=(255&s)/255*e},toHexString:({r:t,g:i,b:e},s=1)=>n.toHexString(t*(s=255/s)<<16^i*s<<8^e*s<<0)},o=[s,n,l,r];class a extends t{constructor(t,i,s,n){var l;super(t,i,s,"color"),this.$input=document.createElement("input"),this.$input.setAttribute("type","color"),this.$input.setAttribute("tabindex",-1),this.$input.setAttribute("aria-labelledby",this.$name.id),this.$text=document.createElement("input"),this.$text.setAttribute("type","text"),this.$text.setAttribute("spellcheck","false"),this.$text.setAttribute("aria-labelledby",this.$name.id),this.$display=document.createElement("div"),this.$display.classList.add("display"),this.$display.appendChild(this.$input),this.$widget.appendChild(this.$display),this.$widget.appendChild(this.$text),this._format=(l=this.initialValue,o.find(t=>t.match(l))),this._rgbScale=n,this._initialValueHexString=this.save(),this._textFocused=!1,this.$input.addEventListener("input",()=>{this._setValueFromHexString(this.$input.value)}),this.$input.addEventListener("blur",()=>{this._callOnFinishChange()}),this.$text.addEventListener("input",()=>{const t=e(this.$text.value);t&&this._setValueFromHexString(t)}),this.$text.addEventListener("focus",()=>{this._textFocused=!0,this.$text.select()}),this.$text.addEventListener("blur",()=>{this._textFocused=!1,this.updateDisplay(),this._callOnFinishChange()}),this.$disable=this.$text,this.updateDisplay()}reset(){return this._setValueFromHexString(this._initialValueHexString),this}_setValueFromHexString(t){if(this._format.isPrimitive){const i=this._format.fromHexString(t);this.setValue(i)}else this._format.fromHexString(t,this.getValue(),this._rgbScale),this._callOnChange(),this.updateDisplay()}save(){return this._format.toHexString(this.getValue(),this._rgbScale)}load(t){return this._setValueFromHexString(t),this._callOnFinishChange(),this}updateDisplay(){return this.$input.value=this._format.toHexString(this.getValue(),this._rgbScale),this._textFocused||(this.$text.value=this.$input.value.substring(1)),this.$display.style.backgroundColor=this.$input.value,this}}class h extends t{constructor(t,i,e){super(t,i,e,"function"),this.$button=document.createElement("button"),this.$button.appendChild(this.$name),this.$widget.appendChild(this.$button),this.$button.addEventListener("click",t=>{t.preventDefault(),this.getValue().call(this.object)}),this.$button.addEventListener("touchstart",()=>{},{passive:!0}),this.$disable=this.$button}}class d extends t{constructor(t,i,e,s,n,l){super(t,i,e,"number"),this._initInput(),this.min(s),this.max(n);const r=void 0!==l;this.step(r?l:this._getImplicitStep(),r),this.updateDisplay()}decimals(t){return this._decimals=t,this.updateDisplay(),this}min(t){return this._min=t,this._onUpdateMinMax(),this}max(t){return this._max=t,this._onUpdateMinMax(),this}step(t,i=!0){return this._step=t,this._stepExplicit=i,this}updateDisplay(){const t=this.getValue();if(this._hasSlider){let i=(t-this._min)/(this._max-this._min);i=Math.max(0,Math.min(i,1)),this.$fill.style.width=100*i+"%"}return this._inputFocused||(this.$input.value=void 0===this._decimals?t:t.toFixed(this._decimals)),this}_initInput(){this.$input=document.createElement("input"),this.$input.setAttribute("type","number"),this.$input.setAttribute("step","any"),this.$input.setAttribute("aria-labelledby",this.$name.id),this.$widget.appendChild(this.$input),this.$disable=this.$input;const t=t=>{const i=parseFloat(this.$input.value);isNaN(i)||(this._snapClampSetValue(i+t),this.$input.value=this.getValue())};let i,e,s,n,l,r=!1;const o=t=>{if(r){const s=t.clientX-i,n=t.clientY-e;Math.abs(n)>5?(t.preventDefault(),this.$input.blur(),r=!1,this._setDraggingStyle(!0,"vertical")):Math.abs(s)>5&&a()}if(!r){const i=t.clientY-s;l-=i*this._step*this._arrowKeyMultiplier(t),n+l>this._max?l=this._max-n:n+l{this._setDraggingStyle(!1,"vertical"),this._callOnFinishChange(),window.removeEventListener("mousemove",o),window.removeEventListener("mouseup",a)};this.$input.addEventListener("input",()=>{let t=parseFloat(this.$input.value);isNaN(t)||(this._stepExplicit&&(t=this._snap(t)),this.setValue(this._clamp(t)))}),this.$input.addEventListener("keydown",i=>{"Enter"===i.code&&this.$input.blur(),"ArrowUp"===i.code&&(i.preventDefault(),t(this._step*this._arrowKeyMultiplier(i))),"ArrowDown"===i.code&&(i.preventDefault(),t(this._step*this._arrowKeyMultiplier(i)*-1))}),this.$input.addEventListener("wheel",i=>{this._inputFocused&&(i.preventDefault(),t(this._step*this._normalizeMouseWheel(i)))},{passive:!1}),this.$input.addEventListener("mousedown",t=>{i=t.clientX,e=s=t.clientY,r=!0,n=this.getValue(),l=0,window.addEventListener("mousemove",o),window.addEventListener("mouseup",a)}),this.$input.addEventListener("focus",()=>{this._inputFocused=!0}),this.$input.addEventListener("blur",()=>{this._inputFocused=!1,this.updateDisplay(),this._callOnFinishChange()})}_initSlider(){this._hasSlider=!0,this.$slider=document.createElement("div"),this.$slider.classList.add("slider"),this.$fill=document.createElement("div"),this.$fill.classList.add("fill"),this.$slider.appendChild(this.$fill),this.$widget.insertBefore(this.$slider,this.$input),this.domElement.classList.add("hasSlider");const t=t=>{const i=this.$slider.getBoundingClientRect();let e=(s=t,n=i.left,l=i.right,r=this._min,o=this._max,(s-n)/(l-n)*(o-r)+r);var s,n,l,r,o;this._snapClampSetValue(e)},i=i=>{t(i.clientX)},e=()=>{this._callOnFinishChange(),this._setDraggingStyle(!1),window.removeEventListener("mousemove",i),window.removeEventListener("mouseup",e)};let s,n,l=!1;const r=i=>{i.preventDefault(),this._setDraggingStyle(!0),t(i.touches[0].clientX),l=!1},o=i=>{if(l){const t=i.touches[0].clientX-s,e=i.touches[0].clientY-n;Math.abs(t)>Math.abs(e)?r(i):(window.removeEventListener("touchmove",o),window.removeEventListener("touchend",a))}else i.preventDefault(),t(i.touches[0].clientX)},a=()=>{this._callOnFinishChange(),this._setDraggingStyle(!1),window.removeEventListener("touchmove",o),window.removeEventListener("touchend",a)},h=this._callOnFinishChange.bind(this);let d;this.$slider.addEventListener("mousedown",s=>{this._setDraggingStyle(!0),t(s.clientX),window.addEventListener("mousemove",i),window.addEventListener("mouseup",e)}),this.$slider.addEventListener("touchstart",t=>{t.touches.length>1||(this._hasScrollBar?(s=t.touches[0].clientX,n=t.touches[0].clientY,l=!0):r(t),window.addEventListener("touchmove",o,{passive:!1}),window.addEventListener("touchend",a))},{passive:!1}),this.$slider.addEventListener("wheel",t=>{if(Math.abs(t.deltaX)this._max&&(t=this._max),t}_snapClampSetValue(t){this.setValue(this._clamp(this._snap(t)))}get _hasScrollBar(){const t=this.parent.root.$children;return t.scrollHeight>t.clientHeight}get _hasMin(){return void 0!==this._min}get _hasMax(){return void 0!==this._max}}class c extends t{constructor(t,i,e,s){super(t,i,e,"option"),this.$select=document.createElement("select"),this.$select.setAttribute("aria-labelledby",this.$name.id),this.$display=document.createElement("div"),this.$display.classList.add("display"),this._values=Array.isArray(s)?s:Object.values(s),this._names=Array.isArray(s)?s:Object.keys(s),this._names.forEach(t=>{const i=document.createElement("option");i.innerHTML=t,this.$select.appendChild(i)}),this.$select.addEventListener("change",()=>{this.setValue(this._values[this.$select.selectedIndex]),this._callOnFinishChange()}),this.$select.addEventListener("focus",()=>{this.$display.classList.add("focus")}),this.$select.addEventListener("blur",()=>{this.$display.classList.remove("focus")}),this.$widget.appendChild(this.$select),this.$widget.appendChild(this.$display),this.$disable=this.$select,this.updateDisplay()}updateDisplay(){const t=this.getValue(),i=this._values.indexOf(t);return this.$select.selectedIndex=i,this.$display.innerHTML=-1===i?t:this._names[i],this}}class u extends t{constructor(t,i,e){super(t,i,e,"string"),this.$input=document.createElement("input"),this.$input.setAttribute("type","text"),this.$input.setAttribute("aria-labelledby",this.$name.id),this.$input.addEventListener("input",()=>{this.setValue(this.$input.value)}),this.$input.addEventListener("keydown",t=>{"Enter"===t.code&&this.$input.blur()}),this.$input.addEventListener("blur",()=>{this._callOnFinishChange()}),this.$widget.appendChild(this.$input),this.$disable=this.$input,this.updateDisplay()}updateDisplay(){return this.$input.value=this.getValue(),this}}let p=!1;class g{constructor({parent:t,autoPlace:i=void 0===t,container:e,width:s,title:n="Controls",injectStyles:l=!0,touchStyles:r=!0}={}){if(this.parent=t,this.root=t?t.root:this,this.children=[],this.controllers=[],this.folders=[],this._closed=!1,this._hidden=!1,this.domElement=document.createElement("div"),this.domElement.classList.add("lil-gui"),this.$title=document.createElement("div"),this.$title.classList.add("title"),this.$title.setAttribute("role","button"),this.$title.setAttribute("aria-expanded",!0),this.$title.setAttribute("tabindex",0),this.$title.addEventListener("click",()=>this.openAnimated(this._closed)),this.$title.addEventListener("keydown",t=>{"Enter"!==t.code&&"Space"!==t.code||(t.preventDefault(),this.$title.click())}),this.$title.addEventListener("touchstart",()=>{},{passive:!0}),this.$children=document.createElement("div"),this.$children.classList.add("children"),this.domElement.appendChild(this.$title),this.domElement.appendChild(this.$children),this.title(n),r&&this.domElement.classList.add("allow-touch-styles"),this.parent)return this.parent.children.push(this),this.parent.folders.push(this),void this.parent.$children.appendChild(this.domElement);this.domElement.classList.add("root"),!p&&l&&(!function(t){const i=document.createElement("style");i.innerHTML=t;const e=document.querySelector("head link[rel=stylesheet], head style");e?document.head.insertBefore(i,e):document.head.appendChild(i)}('.lil-gui{--background-color:#1f1f1f;--text-color:#ebebeb;--title-background-color:#111;--title-text-color:#ebebeb;--widget-color:#424242;--hover-color:#4f4f4f;--focus-color:#595959;--number-color:#2cc9ff;--string-color:#a2db3c;--font-size:11px;--input-font-size:11px;--font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;--font-family-mono:Menlo,Monaco,Consolas,"Droid Sans Mono",monospace;--padding:4px;--spacing:4px;--widget-height:20px;--name-width:45%;--slider-knob-width:2px;--slider-input-width:27%;--color-input-width:27%;--slider-input-min-width:45px;--color-input-min-width:45px;--folder-indent:7px;--widget-padding:0 0 0 3px;--widget-border-radius:2px;--checkbox-size:calc(var(--widget-height)*0.75);--scrollbar-width:5px;background-color:var(--background-color);color:var(--text-color);font-family:var(--font-family);font-size:var(--font-size);font-style:normal;font-weight:400;line-height:1;text-align:left;touch-action:manipulation;user-select:none;-webkit-user-select:none}.lil-gui,.lil-gui *{box-sizing:border-box;margin:0;padding:0}.lil-gui.root{display:flex;flex-direction:column;width:var(--width,245px)}.lil-gui.root>.title{background:var(--title-background-color);color:var(--title-text-color)}.lil-gui.root>.children{overflow-x:hidden;overflow-y:auto}.lil-gui.root>.children::-webkit-scrollbar{background:var(--background-color);height:var(--scrollbar-width);width:var(--scrollbar-width)}.lil-gui.root>.children::-webkit-scrollbar-thumb{background:var(--focus-color);border-radius:var(--scrollbar-width)}.lil-gui.force-touch-styles{--widget-height:28px;--padding:6px;--spacing:6px;--font-size:13px;--input-font-size:16px;--folder-indent:10px;--scrollbar-width:7px;--slider-input-min-width:50px;--color-input-min-width:65px}.lil-gui.autoPlace{max-height:100%;position:fixed;right:15px;top:0;z-index:1001}.lil-gui .controller{align-items:center;display:flex;margin:var(--spacing) 0;padding:0 var(--padding)}.lil-gui .controller.disabled{opacity:.5}.lil-gui .controller.disabled,.lil-gui .controller.disabled *{pointer-events:none!important}.lil-gui .controller>.name{flex-shrink:0;line-height:var(--widget-height);min-width:var(--name-width);padding-right:var(--spacing);white-space:pre}.lil-gui .controller .widget{align-items:center;display:flex;min-height:var(--widget-height);position:relative;width:100%}.lil-gui .controller.string input{color:var(--string-color)}.lil-gui .controller.boolean .widget{cursor:pointer}.lil-gui .controller.color .display{border-radius:var(--widget-border-radius);height:var(--widget-height);position:relative;width:100%}.lil-gui .controller.color input[type=color]{cursor:pointer;height:100%;opacity:0;width:100%}.lil-gui .controller.color input[type=text]{flex-shrink:0;font-family:var(--font-family-mono);margin-left:var(--spacing);min-width:var(--color-input-min-width);width:var(--color-input-width)}.lil-gui .controller.option select{max-width:100%;opacity:0;position:absolute;width:100%}.lil-gui .controller.option .display{background:var(--widget-color);border-radius:var(--widget-border-radius);height:var(--widget-height);line-height:var(--widget-height);max-width:100%;overflow:hidden;padding-left:.55em;padding-right:1.75em;pointer-events:none;position:relative;word-break:break-all}.lil-gui .controller.option .display.active{background:var(--focus-color)}.lil-gui .controller.option .display:after{bottom:0;content:"↕";font-family:lil-gui;padding-right:.375em;position:absolute;right:0;top:0}.lil-gui .controller.option .widget,.lil-gui .controller.option select{cursor:pointer}.lil-gui .controller.number input{color:var(--number-color)}.lil-gui .controller.number.hasSlider input{flex-shrink:0;margin-left:var(--spacing);min-width:var(--slider-input-min-width);width:var(--slider-input-width)}.lil-gui .controller.number .slider{background-color:var(--widget-color);border-radius:var(--widget-border-radius);cursor:ew-resize;height:var(--widget-height);overflow:hidden;padding-right:var(--slider-knob-width);touch-action:pan-y;width:100%}.lil-gui .controller.number .slider.active{background-color:var(--focus-color)}.lil-gui .controller.number .slider.active .fill{opacity:.95}.lil-gui .controller.number .fill{border-right:var(--slider-knob-width) solid var(--number-color);box-sizing:content-box;height:100%}.lil-gui-dragging .lil-gui{--hover-color:var(--widget-color)}.lil-gui-dragging *{cursor:ew-resize!important}.lil-gui-dragging.lil-gui-vertical *{cursor:ns-resize!important}.lil-gui .title{--title-height:calc(var(--widget-height) + var(--spacing)*1.25);-webkit-tap-highlight-color:transparent;text-decoration-skip:objects;cursor:pointer;font-weight:600;height:var(--title-height);line-height:calc(var(--title-height) - 4px);outline:none;padding:0 var(--padding)}.lil-gui .title:before{content:"▾";display:inline-block;font-family:lil-gui;padding-right:2px}.lil-gui .title:active{background:var(--title-background-color);opacity:.75}.lil-gui.root>.title:focus{text-decoration:none!important}.lil-gui.closed>.title:before{content:"▸"}.lil-gui.closed>.children{opacity:0;transform:translateY(-7px)}.lil-gui.closed:not(.transition)>.children{display:none}.lil-gui.transition>.children{overflow:hidden;pointer-events:none;transition-duration:.3s;transition-property:height,opacity,transform;transition-timing-function:cubic-bezier(.2,.6,.35,1)}.lil-gui .children:empty:before{content:"Empty";display:block;font-style:italic;height:var(--widget-height);line-height:var(--widget-height);margin:var(--spacing) 0;opacity:.5;padding:0 var(--padding)}.lil-gui.root>.children>.lil-gui>.title{border-width:0;border-bottom:1px solid var(--widget-color);border-left:0 solid var(--widget-color);border-right:0 solid var(--widget-color);border-top:1px solid var(--widget-color);transition:border-color .3s}.lil-gui.root>.children>.lil-gui.closed>.title{border-bottom-color:transparent}.lil-gui+.controller{border-top:1px solid var(--widget-color);margin-top:0;padding-top:var(--spacing)}.lil-gui .lil-gui .lil-gui>.title{border:none}.lil-gui .lil-gui .lil-gui>.children{border:none;border-left:2px solid var(--widget-color);margin-left:var(--folder-indent)}.lil-gui .lil-gui .controller{border:none}.lil-gui input{-webkit-tap-highlight-color:transparent;background:var(--widget-color);border:0;border-radius:var(--widget-border-radius);color:var(--text-color);font-family:var(--font-family);font-size:var(--input-font-size);height:var(--widget-height);outline:none;width:100%}.lil-gui input:disabled{opacity:1}.lil-gui input[type=number],.lil-gui input[type=text]{padding:var(--widget-padding)}.lil-gui input[type=number]:focus,.lil-gui input[type=text]:focus{background:var(--focus-color)}.lil-gui input::-webkit-inner-spin-button,.lil-gui input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.lil-gui input[type=number]{-moz-appearance:textfield}.lil-gui input[type=checkbox]{appearance:none;-webkit-appearance:none;border-radius:var(--widget-border-radius);cursor:pointer;height:var(--checkbox-size);text-align:center;width:var(--checkbox-size)}.lil-gui input[type=checkbox]:checked:before{content:"✓";font-family:lil-gui;font-size:var(--checkbox-size);line-height:var(--checkbox-size)}.lil-gui button{-webkit-tap-highlight-color:transparent;background:var(--widget-color);border:1px solid var(--widget-color);border-radius:var(--widget-border-radius);color:var(--text-color);cursor:pointer;font-family:var(--font-family);font-size:var(--font-size);height:var(--widget-height);line-height:calc(var(--widget-height) - 4px);outline:none;text-align:center;text-transform:none;width:100%}.lil-gui button:active{background:var(--focus-color)}@font-face{font-family:lil-gui;src:url("data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAUsAAsAAAAACJwAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAAH4AAADAImwmYE9TLzIAAAGIAAAAPwAAAGBKqH5SY21hcAAAAcgAAAD0AAACrukyyJBnbHlmAAACvAAAAF8AAACEIZpWH2hlYWQAAAMcAAAAJwAAADZfcj2zaGhlYQAAA0QAAAAYAAAAJAC5AHhobXR4AAADXAAAABAAAABMAZAAAGxvY2EAAANsAAAAFAAAACgCEgIybWF4cAAAA4AAAAAeAAAAIAEfABJuYW1lAAADoAAAASIAAAIK9SUU/XBvc3QAAATEAAAAZgAAAJCTcMc2eJxVjbEOgjAURU+hFRBK1dGRL+ALnAiToyMLEzFpnPz/eAshwSa97517c/MwwJmeB9kwPl+0cf5+uGPZXsqPu4nvZabcSZldZ6kfyWnomFY/eScKqZNWupKJO6kXN3K9uCVoL7iInPr1X5baXs3tjuMqCtzEuagm/AAlzQgPAAB4nGNgYRBlnMDAysDAYM/gBiT5oLQBAwuDJAMDEwMrMwNWEJDmmsJwgCFeXZghBcjlZMgFCzOiKOIFAB71Bb8AeJy1kjFuwkAQRZ+DwRAwBtNQRUGKQ8OdKCAWUhAgKLhIuAsVSpWz5Bbkj3dEgYiUIszqWdpZe+Z7/wB1oCYmIoboiwiLT2WjKl/jscrHfGg/pKdMkyklC5Zs2LEfHYpjcRoPzme9MWWmk3dWbK9ObkWkikOetJ554fWyoEsmdSlt+uR0pCJR34b6t/TVg1SY3sYvdf8vuiKrpyaDXDISiegp17p7579Gp3p++y7HPAiY9pmTibljrr85qSidtlg4+l25GLCaS8e6rRxNBmsnERunKbaOObRz7N72ju5vdAjYpBXHgJylOAVsMseDAPEP8LYoUHicY2BiAAEfhiAGJgZWBgZ7RnFRdnVJELCQlBSRlATJMoLV2DK4glSYs6ubq5vbKrJLSbGrgEmovDuDJVhe3VzcXFwNLCOILB/C4IuQ1xTn5FPilBTj5FPmBAB4WwoqAHicY2BkYGAA4sk1sR/j+W2+MnAzpDBgAyEMQUCSg4EJxAEAwUgFHgB4nGNgZGBgSGFggJMhDIwMqEAYAByHATJ4nGNgAIIUNEwmAABl3AGReJxjYAACIQYlBiMGJ3wQAEcQBEV4nGNgZGBgEGZgY2BiAAEQyQWEDAz/wXwGAAsPATIAAHicXdBNSsNAHAXwl35iA0UQXYnMShfS9GPZA7T7LgIu03SSpkwzYTIt1BN4Ak/gKTyAeCxfw39jZkjymzcvAwmAW/wgwHUEGDb36+jQQ3GXGot79L24jxCP4gHzF/EIr4jEIe7wxhOC3g2TMYy4Q7+Lu/SHuEd/ivt4wJd4wPxbPEKMX3GI5+DJFGaSn4qNzk8mcbKSR6xdXdhSzaOZJGtdapd4vVPbi6rP+cL7TGXOHtXKll4bY1Xl7EGnPtp7Xy2n00zyKLVHfkHBa4IcJ2oD3cgggWvt/V/FbDrUlEUJhTn/0azVWbNTNr0Ens8de1tceK9xZmfB1CPjOmPH4kitmvOubcNpmVTN3oFJyjzCvnmrwhJTzqzVj9jiSX911FjeAAB4nG3HMRKCMBBA0f0giiKi4DU8k0V2GWbIZDOh4PoWWvq6J5V8If9NVNQcaDhyouXMhY4rPTcG7jwYmXhKq8Wz+p762aNaeYXom2n3m2dLTVgsrCgFJ7OTmIkYbwIbC6vIB7WmFfAAAA==") format("woff")}@media (pointer:coarse){.lil-gui.allow-touch-styles{--widget-height:28px;--padding:6px;--spacing:6px;--font-size:13px;--input-font-size:16px;--folder-indent:10px;--scrollbar-width:7px;--slider-input-min-width:50px;--color-input-min-width:65px}}@media (hover:hover){.lil-gui .controller.color .display:hover:before{border:1px solid #fff9;border-radius:var(--widget-border-radius);bottom:0;content:" ";display:block;left:0;position:absolute;right:0;top:0}.lil-gui .controller.option .display.focus{background:var(--focus-color)}.lil-gui .controller.option .widget:hover .display{background:var(--hover-color)}.lil-gui .controller.number .slider:hover{background-color:var(--hover-color)}body:not(.lil-gui-dragging) .lil-gui .title:hover{background:var(--title-background-color);opacity:.85}.lil-gui .title:focus{text-decoration:underline var(--focus-color)}.lil-gui input:hover{background:var(--hover-color)}.lil-gui input:active{background:var(--focus-color)}.lil-gui input[type=checkbox]:focus{box-shadow:inset 0 0 0 1px var(--focus-color)}.lil-gui button:hover{background:var(--hover-color);border-color:var(--hover-color)}.lil-gui button:focus{border-color:var(--focus-color)}}'),p=!0),e?e.appendChild(this.domElement):i&&(this.domElement.classList.add("autoPlace"),document.body.appendChild(this.domElement)),s&&this.domElement.style.setProperty("--width",s+"px"),this.domElement.addEventListener("keydown",t=>t.stopPropagation()),this.domElement.addEventListener("keyup",t=>t.stopPropagation())}add(t,e,s,n,l){if(Object(s)===s)return new c(this,t,e,s);const r=t[e];switch(typeof r){case"number":return new d(this,t,e,s,n,l);case"boolean":return new i(this,t,e);case"string":return new u(this,t,e);case"function":return new h(this,t,e)}console.error("gui.add failed\n\tproperty:",e,"\n\tobject:",t,"\n\tvalue:",r)}addColor(t,i,e=1){return new a(this,t,i,e)}addFolder(t){return new g({parent:this,title:t})}load(t,i=!0){return t.controllers&&this.controllers.forEach(i=>{i instanceof h||i._name in t.controllers&&i.load(t.controllers[i._name])}),i&&t.folders&&this.folders.forEach(i=>{i._title in t.folders&&i.load(t.folders[i._title])}),this}save(t=!0){const i={controllers:{},folders:{}};return this.controllers.forEach(t=>{if(!(t instanceof h)){if(t._name in i.controllers)throw new Error(`Cannot save GUI with duplicate property "${t._name}"`);i.controllers[t._name]=t.save()}}),t&&this.folders.forEach(t=>{if(t._title in i.folders)throw new Error(`Cannot save GUI with duplicate folder "${t._title}"`);i.folders[t._title]=t.save()}),i}open(t=!0){return this._closed=!t,this.$title.setAttribute("aria-expanded",!this._closed),this.domElement.classList.toggle("closed",this._closed),this}close(){return this.open(!1)}show(t=!0){return this._hidden=!t,this.domElement.style.display=this._hidden?"none":"",this}hide(){return this.show(!1)}openAnimated(t=!0){return this._closed=!t,this.$title.setAttribute("aria-expanded",!this._closed),requestAnimationFrame(()=>{const i=this.$children.clientHeight;this.$children.style.height=i+"px",this.domElement.classList.add("transition");const e=t=>{t.target===this.$children&&(this.$children.style.height="",this.domElement.classList.remove("transition"),this.$children.removeEventListener("transitionend",e))};this.$children.addEventListener("transitionend",e);const s=t?this.$children.scrollHeight:0;this.domElement.classList.toggle("closed",!t),requestAnimationFrame(()=>{this.$children.style.height=s+"px"})}),this}title(t){return this._title=t,this.$title.innerHTML=t,this}reset(t=!0){return(t?this.controllersRecursive():this.controllers).forEach(t=>t.reset()),this}onChange(t){return this._onChange=t,this}_callOnChange(t){this.parent&&this.parent._callOnChange(t),void 0!==this._onChange&&this._onChange.call(this,{object:t.object,property:t.property,value:t.getValue(),controller:t})}onFinishChange(t){return this._onFinishChange=t,this}_callOnFinishChange(t){this.parent&&this.parent._callOnFinishChange(t),void 0!==this._onFinishChange&&this._onFinishChange.call(this,{object:t.object,property:t.property,value:t.getValue(),controller:t})}destroy(){this.parent&&(this.parent.children.splice(this.parent.children.indexOf(this),1),this.parent.folders.splice(this.parent.folders.indexOf(this),1)),this.domElement.parentElement&&this.domElement.parentElement.removeChild(this.domElement),Array.from(this.children).forEach(t=>t.destroy())}controllersRecursive(){let t=Array.from(this.controllers);return this.folders.forEach(i=>{t=t.concat(i.controllersRecursive())}),t}foldersRecursive(){let t=Array.from(this.folders);return this.folders.forEach(i=>{t=t.concat(i.foldersRecursive())}),t}}export default g;export{i as BooleanController,a as ColorController,t as Controller,h as FunctionController,g as GUI,d as NumberController,c as OptionController,u as StringController}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05cffd6a..b5aedb71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { AlertQueue, AlertQueueProvider } from "@/hooks/useAlertQueue"; import { AuthenticationProvider } from "@/hooks/useAuth"; import DownloadsPage from "./components/pages/Download"; +import MuJoCoTestPage from "./components/pages/MuJoCoTest"; import PrivacyPolicy from "./components/pages/PrivacyPolicy"; import TermsOfService from "./components/pages/TermsOfService"; @@ -48,6 +49,8 @@ const App = () => { } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/listing/mujoco/index.tsx b/frontend/src/components/listing/mujoco/index.tsx new file mode 100644 index 00000000..e36f525d --- /dev/null +++ b/frontend/src/components/listing/mujoco/index.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef } from "react"; + +const MUJOCO = ({ url }: { url: string }) => { + const appBodyRef = useRef(null); + const scriptRef = useRef(null); + + useEffect(() => { + const setupComponent = async () => { + if (appBodyRef.current) { + // Step 1: Set initial HTML content with styled container + const htmlContent = ` +
+ +
+
+ `; + appBodyRef.current.innerHTML = htmlContent; + + scriptRef.current = document.createElement("script"); + scriptRef.current.type = "module"; + scriptRef.current.src = "/examples/main.js"; + appBodyRef.current + ?.querySelector("#appbody") + ?.appendChild(scriptRef.current); + } + }; + + setupComponent(); + + // Cleanup function + return () => { + if (scriptRef.current && scriptRef.current.parentNode) { + scriptRef.current.parentNode.removeChild(scriptRef.current); + } + if (appBodyRef.current) { + appBodyRef.current.innerHTML = ""; + } + // Add any additional cleanup here, e.g., stopping WebAssembly instances + }; + }, []); + + return ( +
+
+
+ ); +}; + +export default MUJOCO; diff --git a/frontend/src/components/pages/MuJoCoTest.tsx b/frontend/src/components/pages/MuJoCoTest.tsx new file mode 100644 index 00000000..c146b206 --- /dev/null +++ b/frontend/src/components/pages/MuJoCoTest.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +import MUJOCO from "../listing/mujoco"; + +const MuJoCoTestPage: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default MuJoCoTestPage;