Manager
View Site
Name
Type
React (.jsx)
HTML (.html)
Icon
Description
Code Editor
Revision History (0)
<!DOCTYPE html> <html lang="en"> <head> <title>VR CAD Studio</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <style> body { margin: 0; background-color: #111; font-family: sans-serif; overflow: hidden; } #info { position: absolute; top: 10px; width: 100%; text-align: center; color: #fff; pointer-events: none; z-index: 1; } #overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; color: white; flex-direction: column; z-index: 2; } button { padding: 12px 24px; font-size: 18px; cursor: pointer; background: #4caf50; color: white; border: none; border-radius: 4px; margin-top: 20px; } button:hover { background: #45a049; } </style> <!-- Import Maps for Three.js and Addons --> <script type="importmap"> { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/", "three-mesh-bvh": "https://unpkg.com/three-mesh-bvh@0.7.3/build/index.module.js", "three-bvh-csg": "https://unpkg.com/three-bvh-csg@0.0.16/build/index.module.js" } } </script> </head> <body> <div id="info">VR CAD Studio<br/>Two-Hand Grab to Scale/Rotate | Thumbstick to Push/Pull</div> <div id="overlay"> <h1>VR CAD Studio</h1> <p>Supports Quest, Vive, and WebXR devices.</p> <p>Ensure you have a VR headset connected.</p> <button id="start-btn">Enter VR Mode</button> </div> <script type="module"> import * as THREE from 'three'; import { VRButton } from 'three/addons/webxr/VRButton.js'; import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js'; import { STLExporter } from 'three/addons/exporters/STLExporter.js'; import { SUBTRACTION, INTERSECTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; let camera, scene, renderer; let controller1, controller2; let controllerGrip1, controllerGrip2; // Raycasting let raycaster; const intersected = []; let tempMatrix = new THREE.Matrix4(); // State let selectedTarget = null; let selectedBrush = null; let objects = []; // UI let uiMesh; let uiCanvas, uiCtx; let uiTexture; // Sketching State let isSketching = false; let sketchPlane = null; let sketchPoints = []; let sketchLine = null; let sketchMarkers = []; // Grab / Manipulation State // We map Object -> Set of Controllers holding it const objectGrabbers = new Map(); // We map Controller -> Object held (for single hand logic) const controllerGrabbedObject = new Map(); // Two-handed manipulation data const twoHandData = { active: false, object: null, initialDistance: 0, initialScale: new THREE.Vector3(), initialAngle: 0, // Y-axis rotation initialObjectRotationY: 0 }; // CSG Evaluator const csgEvaluator = new Evaluator(); init(); animate(); function init() { const container = document.createElement('div'); document.body.appendChild(container); scene = new THREE.Scene(); scene.background = new THREE.Color(0x202020); camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 50); camera.position.set(0, 1.6, 3); // Lighting scene.add(new THREE.HemisphereLight(0x808080, 0x606060)); const light = new THREE.DirectionalLight(0xffffff); light.position.set(0, 6, 0); light.castShadow = true; light.shadow.camera.top = 2; light.shadow.camera.bottom = -2; light.shadow.camera.right = 2; light.shadow.camera.left = -2; light.shadow.mapSize.set(4096, 4096); scene.add(light); // Floor const floorGeometry = new THREE.PlaneGeometry(10, 10); const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 1.0, metalness: 0.0 }); const floor = new THREE.Mesh(floorGeometry, floorMaterial); floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor); // Grid const grid = new THREE.GridHelper(10, 20, 0x444444, 0x444444); scene.add(grid); // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.xr.enabled = true; container.appendChild(renderer.domElement); document.body.appendChild(VRButton.createButton(renderer)); document.getElementById('start-btn').addEventListener('click', () => { document.getElementById('overlay').style.display = 'none'; }); // VR Controllers controller1 = renderer.xr.getController(0); controller1.addEventListener('selectstart', onSelectStart); controller1.addEventListener('selectend', onSelectEnd); controller1.addEventListener('squeezestart', onSqueezeStart); controller1.addEventListener('squeezeend', onSqueezeEnd); controller1.userData.isSelecting = false; controller1.userData.isSqueezing = false; scene.add(controller1); controller2 = renderer.xr.getController(1); controller2.addEventListener('selectstart', onSelectStart); controller2.addEventListener('selectend', onSelectEnd); controller2.addEventListener('squeezestart', onSqueezeStart); controller2.addEventListener('squeezeend', onSqueezeEnd); controller2.userData.isSelecting = false; controller2.userData.isSqueezing = false; scene.add(controller2); const controllerModelFactory = new XRControllerModelFactory(); controllerGrip1 = renderer.xr.getControllerGrip(0); controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1)); scene.add(controllerGrip1); controllerGrip2 = renderer.xr.getControllerGrip(1); controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2)); scene.add(controllerGrip2); // Laser Pointers const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1)]); const line = new THREE.Line(geometry); line.name = 'line'; line.scale.z = 5; controller1.add(line.clone()); controller2.add(line.clone()); raycaster = new THREE.Raycaster(); // Create 3D UI createVRUI(); // Event Listeners window.addEventListener('resize', onWindowResize); } // --- UI SYSTEM --- function createVRUI() { uiCanvas = document.createElement('canvas'); uiCanvas.width = 1024; uiCanvas.height = 1024; uiCtx = uiCanvas.getContext('2d'); uiTexture = new THREE.CanvasTexture(uiCanvas); drawUI(); const uiMaterial = new THREE.MeshBasicMaterial({ map: uiTexture, transparent: true, opacity: 0.95 }); const uiGeometry = new THREE.PlaneGeometry(1, 1); uiMesh = new THREE.Mesh(uiGeometry, uiMaterial); uiMesh.position.set(0, 1.5, -1.2); uiMesh.name = "UI_Panel"; scene.add(uiMesh); } function drawUI() { uiCtx.fillStyle = '#222'; uiCtx.fillRect(0, 0, uiCanvas.width, uiCanvas.height); uiCtx.lineWidth = 10; uiCtx.strokeStyle = '#4CAF50'; uiCtx.strokeRect(0, 0, uiCanvas.width, uiCanvas.height); uiCtx.textAlign = 'center'; uiCtx.textBaseline = 'middle'; uiCtx.fillStyle = '#fff'; uiCtx.font = 'bold 40px Arial'; uiCtx.fillText("VR CAD STUDIO", 512, 50); if (isSketching) { drawSketchUI(); } else if (selectedTarget) { drawTransformUI(); } else { drawMainUI(); } if (uiTexture) uiTexture.needsUpdate = true; } function drawMainUI() { uiCtx.font = '24px Arial'; uiCtx.fillStyle = '#aaa'; uiCtx.fillText("Mode: CREATE & COMBINE", 512, 100); drawButton(50, 150, 280, 80, "ADD CUBE", "#444"); drawButton(370, 150, 280, 80, "ADD SPHERE", "#444"); drawButton(690, 150, 280, 80, "ADD CYLINDER", "#444"); drawButton(50, 250, 920, 80, "START 2D SKETCH", "#2266aa"); uiCtx.fillStyle = '#666'; uiCtx.font = 'italic 20px Arial'; uiCtx.fillText("Grab with BOTH hands to Scale/Rotate", 512, 450); drawButton(50, 800, 440, 80, "EXPORT STL", "#44aa44"); drawButton(530, 800, 440, 80, "RESET SCENE", "#aa2222"); } function drawTransformUI() { uiCtx.font = '24px Arial'; uiCtx.fillStyle = '#8f8'; uiCtx.fillText(`Selected: ${selectedTarget.userData.type || "Object"}`, 512, 100); drawButton(50, 140, 280, 60, "UNION", "#4466aa"); drawButton(370, 140, 280, 60, "SUBTRACT", "#aa4444"); drawButton(690, 140, 280, 60, "INTERSECT", "#884488"); uiCtx.fillStyle = '#aaa'; uiCtx.font = '24px Arial'; uiCtx.fillText("Use Controllers to Scale/Rotate", 512, 300); uiCtx.fillText("Hold with 2 hands -> Pull apart to scale", 512, 340); uiCtx.fillText("Thumbstick -> Push/Pull Object", 512, 380); drawButton(750, 450, 200, 60, "DESELECT", "#777"); drawButton(750, 530, 200, 60, "DELETE", "#aa2222"); } function drawSketchUI() { uiCtx.font = '24px Arial'; uiCtx.fillStyle = '#88ccff'; uiCtx.fillText("SKETCH MODE", 512, 100); uiCtx.fillStyle = '#aaa'; uiCtx.fillText("Trigger: Add Point | Grip Point: Move | 'A' Btn: Delete Point", 512, 140); uiCtx.fillText("Press 'B' or 'Y' Button to Exit Sketch", 512, 170); drawButton(50, 250, 400, 80, "EXTRUDE SKETCH", "#44aa44"); drawButton(500, 250, 400, 80, "REVOLVE (Y-AXIS)", "#aa44aa"); drawButton(50, 370, 400, 80, "CLEAR POINTS", "#aa6622"); } function drawButton(x, y, w, h, text, color) { uiCtx.fillStyle = color; uiCtx.fillRect(x, y, w, h); uiCtx.strokeStyle = '#fff'; uiCtx.lineWidth = 2; uiCtx.strokeRect(x, y, w, h); uiCtx.fillStyle = 'white'; uiCtx.font = 'bold 24px Arial'; uiCtx.textAlign = 'center'; uiCtx.textBaseline = 'middle'; uiCtx.fillText(text, x + w/2, y + h/2); } // --- SKETCHING LOGIC --- function startSketchMode() { isSketching = true; const planeGeom = new THREE.PlaneGeometry(1, 1, 10, 10); const planeMat = new THREE.MeshBasicMaterial({ color: 0x88ccff, opacity: 0.2, transparent: true, side: THREE.DoubleSide }); sketchPlane = new THREE.Mesh(planeGeom, planeMat); sketchPlane.position.set(0, 1.3, -0.5); const grid = new THREE.GridHelper(1, 10, 0xffffff, 0x88ccff); grid.rotation.x = Math.PI/2; sketchPlane.add(grid); scene.add(sketchPlane); drawUI(); } function endSketchMode() { isSketching = false; if (sketchPlane) { scene.remove(sketchPlane); sketchPlane = null; } clearSketchPoints(); drawUI(); } function addSketchPoint(point) { const marker = new THREE.Mesh( new THREE.SphereGeometry(0.015), new THREE.MeshBasicMaterial({ color: 0xffff00 }) ); marker.position.copy(point); marker.userData.isSketchMarker = true; // Tag for raycasting scene.add(marker); sketchMarkers.push(marker); updateSketchLine(); } function updateSketchLine() { if (sketchLine) scene.remove(sketchLine); if (sketchMarkers.length < 2) return; const points = sketchMarkers.map(m => m.position); points.push(sketchMarkers[0].position); // Close loop const geom = new THREE.BufferGeometry().setFromPoints(points); sketchLine = new THREE.Line(geom, new THREE.LineBasicMaterial({ color: 0xffff00, linewidth: 3 })); scene.add(sketchLine); // Re-sync sketchPoints array for generation sketchPoints = sketchMarkers.map(m => { const local = m.position.clone(); if(sketchPlane) sketchPlane.worldToLocal(local); return new THREE.Vector2(local.x, local.y); }); } function clearSketchPoints() { sketchMarkers.forEach(m => scene.remove(m)); sketchMarkers = []; sketchPoints = []; if (sketchLine) { scene.remove(sketchLine); sketchLine = null; } } function finalizeSketch(type) { if (sketchMarkers.length < 3) return; // Ensure sketchPoints are up to date updateSketchLine(); const shape = new THREE.Shape(sketchPoints); let geometry; if (type === 'extrude') { const extrudeSettings = { depth: 0.1, bevelEnabled: false }; geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings); } else if (type === 'revolve') { geometry = new THREE.LatheGeometry(sketchPoints, 32); } geometry.center(); const material = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff, roughness: 0.7 }); const mesh = new THREE.Mesh(geometry, material); mesh.position.copy(sketchPlane.position); mesh.rotation.copy(sketchPlane.rotation); mesh.userData.isModel = true; mesh.userData.type = type === 'extrude' ? 'Extrusion' : 'Revolve'; mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); objects.push(mesh); endSketchMode(); } // --- SHAPE & CSG --- function addShape(type) { let geometry, material, mesh; const size = 0.2; if (type === 'cube') geometry = new THREE.BoxGeometry(size, size, size); else if (type === 'sphere') geometry = new THREE.SphereGeometry(size/1.5, 32, 16); else if (type === 'cylinder') geometry = new THREE.CylinderGeometry(size/2, size/2, size, 32); material = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff, roughness: 0.7 }); mesh = new THREE.Mesh(geometry, material); mesh.position.set(0, 1.3, -0.5); mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData.isModel = true; mesh.userData.type = type; scene.add(mesh); objects.push(mesh); } function performCSG(operation) { if (!selectedTarget || !selectedBrush) return; const brush1 = new Brush(selectedTarget.geometry, selectedTarget.material); brush1.position.copy(selectedTarget.position); brush1.rotation.copy(selectedTarget.rotation); brush1.scale.copy(selectedTarget.scale); brush1.updateMatrixWorld(); const brush2 = new Brush(selectedBrush.geometry, selectedBrush.material); brush2.position.copy(selectedBrush.position); brush2.rotation.copy(selectedBrush.rotation); brush2.scale.copy(selectedBrush.scale); brush2.updateMatrixWorld(); let result; if (operation === 'subtract') result = csgEvaluator.evaluate(brush1, brush2, SUBTRACTION); else if (operation === 'union') result = csgEvaluator.evaluate(brush1, brush2, ADDITION); else if (operation === 'intersect') result = csgEvaluator.evaluate(brush1, brush2, INTERSECTION); if (result) { result.material = selectedTarget.material; result.castShadow = true; result.receiveShadow = true; result.userData.isModel = true; result.userData.type = "CSG_Result"; scene.remove(selectedTarget); scene.remove(selectedBrush); objects = objects.filter(o => o !== selectedTarget && o !== selectedBrush); scene.add(result); objects.push(result); selectedTarget = null; selectedBrush = null; drawUI(); } } function exportSTL() { const exporter = new STLExporter(); const exportGroup = new THREE.Group(); objects.forEach(obj => exportGroup.add(obj.clone())); const result = exporter.parse(exportGroup, { binary: true }); const blob = new Blob([result], { type: 'application/octet-stream' }); const link = document.createElement('a'); link.style.display = 'none'; document.body.appendChild(link); link.href = URL.createObjectURL(blob); link.download = 'vr_model.stl'; link.click(); document.body.removeChild(link); } function resetScene() { objects.forEach(obj => scene.remove(obj)); objects = []; selectedTarget = null; selectedBrush = null; drawUI(); } // --- INPUT HANDLING --- function handleUIClick(uv) { const x = uv.x * 1024; const y = (1 - uv.y) * 1024; if (isSketching) { if (y > 250 && y < 330) { if (x > 50 && x < 450) finalizeSketch('extrude'); if (x > 500 && x < 900) finalizeSketch('revolve'); } else if (y > 370 && y < 450) { if (x > 50 && x < 450) clearSketchPoints(); } return; } if (selectedTarget) { if (y > 140 && y < 200) { if (x > 50 && x < 330) performCSG('union'); else if (x > 370 && x < 650) performCSG('subtract'); else if (x > 690 && x < 970) performCSG('intersect'); } else if (y > 450 && y < 510) { if (x > 750 && x < 950) { // Deselect selectedTarget.material.emissive.setHex(0x000000); selectedTarget = null; if(selectedBrush) selectedBrush.material.emissive.setHex(0x000000); selectedBrush = null; drawUI(); } } else if (y > 530 && y < 590) { if (x > 750 && x < 950) { // Delete scene.remove(selectedTarget); objects = objects.filter(o => o !== selectedTarget); selectedTarget = null; selectedBrush = null; drawUI(); } } return; } if (y > 150 && y < 230) { if (x > 50 && x < 330) addShape('cube'); else if (x > 370 && x < 650) addShape('sphere'); else if (x > 690 && x < 970) addShape('cylinder'); } else if (y > 250 && y < 330) { startSketchMode(); } else if (y > 800 && y < 880) { if (x > 50 && x < 490) exportSTL(); else if (x > 530 && x < 970) resetScene(); } } function onSelectStart(event) { const controller = event.target; controller.userData.isSelecting = true; const intersections = getIntersections(controller); if (intersections.length > 0) { const intersection = intersections[0]; const object = intersection.object; if (object.name === "UI_Panel") { handleUIClick(intersection.uv); return; } if (isSketching && object === sketchPlane) { addSketchPoint(intersection.point); return; } if (object.userData.isModel && !isSketching) { // Logic for selecting Target/Brush (click based) if (selectedTarget === object) { object.material.emissive.setHex(0x000000); selectedTarget = null; } else if (selectedBrush === object) { object.material.emissive.setHex(0x000000); selectedBrush = null; } else if (!selectedTarget) { selectedTarget = object; object.material.emissive.setHex(0x004400); } else { if (selectedBrush) selectedBrush.material.emissive.setHex(0x000000); selectedBrush = object; object.material.emissive.setHex(0x440000); } drawUI(); } } } function onSelectEnd(event) { event.target.userData.isSelecting = false; } // --- ADVANCED CONTROLLER LOGIC --- function onSqueezeStart(event) { const controller = event.target; controller.userData.isSqueezing = true; const intersections = getIntersections(controller); if (intersections.length > 0) { const object = intersections[0].object; // Sketch Marker Dragging if (isSketching && object.userData.isSketchMarker) { controller.attach(object); controllerGrabbedObject.set(controller, object); return; } // Object Grabbing if (object.userData.isModel) { // Register Grab if (!objectGrabbers.has(object)) objectGrabbers.set(object, new Set()); objectGrabbers.get(object).add(controller); const grabberSet = objectGrabbers.get(object); if (grabberSet.size === 1) { // Single hand grab: attach to controller controller.attach(object); controllerGrabbedObject.set(controller, object); } else if (grabberSet.size === 2) { // Two hand grab: detach from parent controller, attach to scene, enable math scene.attach(object); // detach from first controller twoHandData.active = true; twoHandData.object = object; // Calculate initials const c1 = controller1.position; const c2 = controller2.position; twoHandData.initialDistance = c1.distanceTo(c2); twoHandData.initialScale.copy(object.scale); // Angle relative to X-axis in XZ plane const delta = new THREE.Vector3().subVectors(c2, c1); twoHandData.initialAngle = Math.atan2(delta.z, delta.x); twoHandData.initialObjectRotationY = object.rotation.y; } } } } function onSqueezeEnd(event) { const controller = event.target; controller.userData.isSqueezing = false; const grabbedObj = controllerGrabbedObject.get(controller); // Handle Sketch Marker Release if (grabbedObj && grabbedObj.userData.isSketchMarker) { scene.attach(grabbedObj); // Reattach to scene world // Snap to plane Z if(sketchPlane) { // Project back to plane if needed, or just leave in 3D space // (User requested free movement, but for 2D sketch it should ideally stay planar. // For now we allow 3D movement of points). updateSketchLine(); } controllerGrabbedObject.delete(controller); return; } // Handle Model Release // Check if this controller was holding an object shared by objectGrabbers for (const [obj, set] of objectGrabbers) { if (set.has(controller)) { set.delete(controller); if (twoHandData.active && twoHandData.object === obj) { // Released one hand of two twoHandData.active = false; twoHandData.object = null; // If one hand remains, re-attach to that hand if (set.size === 1) { const remainingController = set.values().next().value; remainingController.attach(obj); controllerGrabbedObject.set(remainingController, obj); } } else { // Released single hand if (set.size === 0) { scene.attach(obj); objectGrabbers.delete(obj); } } controllerGrabbedObject.delete(controller); } } } function getIntersections(controller) { tempMatrix.identity().extractRotation(controller.matrixWorld); raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); const arr = [...objects, uiMesh, ...sketchMarkers]; if (sketchPlane) arr.push(sketchPlane); return raycaster.intersectObjects(arr.filter(x => x), false); } // --- MAIN LOOP --- function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } function animate() { renderer.setAnimationLoop(render); } function render() { handleInputPoll(); handleTwoHandedManipulation(); // Dynamic sketch line update while dragging points if (isSketching && (controller1.userData.isSqueezing || controller2.userData.isSqueezing)) { updateSketchLine(); } renderer.render(scene, camera); } function handleInputPoll() { const session = renderer.xr.getSession(); if (!session) return; // Check Input Sources for Gamepad Data for (const source of session.inputSources) { if (!source.gamepad) continue; const handedness = source.handedness; const controller = handedness === 'left' ? controller1 : controller2; if (!controller) continue; const gamepad = source.gamepad; // 1. Thumbstick Push/Pull (Axes[3] usually Y axis) // Only if holding an object alone const heldObj = controllerGrabbedObject.get(controller); if (heldObj && !twoHandData.active && !heldObj.userData.isSketchMarker) { // Deadzone if (Math.abs(gamepad.axes[3]) > 0.1) { const speed = 0.05; heldObj.position.z -= gamepad.axes[3] * speed; } } // 2. Button Handling (A/X, B/Y) // Standard mappings: // [4] = Button A (Right) / X (Left) // [5] = Button B (Right) / Y (Left) // Button B/Y: Exit Sketch if (gamepad.buttons[5] && gamepad.buttons[5].pressed) { if (isSketching) endSketchMode(); } // Button A/X: Delete Sketch Point if (gamepad.buttons[4] && gamepad.buttons[4].pressed) { if (isSketching) { // Raycast from this controller to find markers const intersects = getIntersections(controller); if (intersects.length > 0) { const target = intersects[0].object; if (target.userData.isSketchMarker) { // Delete marker scene.remove(target); sketchMarkers = sketchMarkers.filter(m => m !== target); updateSketchLine(); } } } } } } function handleTwoHandedManipulation() { if (!twoHandData.active || !twoHandData.object) return; const c1 = controller1.position; const c2 = controller2.position; const obj = twoHandData.object; // 1. SCALE logic const currentDist = c1.distanceTo(c2); const scaleFactor = currentDist / twoHandData.initialDistance; // Apply scale uniformly based on initial scale obj.scale.copy(twoHandData.initialScale).multiplyScalar(scaleFactor); // 2. ROTATE logic (Y-axis only for stability) const delta = new THREE.Vector3().subVectors(c2, c1); const currentAngle = Math.atan2(delta.z, delta.x); const angleChange = currentAngle - twoHandData.initialAngle; obj.rotation.y = twoHandData.initialObjectRotationY - angleChange; // 3. Position logic (Midpoint) // Optional: Move object to midpoint of hands // const mid = new THREE.Vector3().addVectors(c1, c2).multiplyScalar(0.5); // obj.position.copy(mid); // NOTE: Attaching to scene usually leaves it in place. // To make it follow hands, we'd need to offset from midpoint. // For now, scaling/rotating in place is safer for UX. } </script> </body> </html>
No revisions found. Save the file to create a backup.
Delete
Update App