{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# ๐Ÿš€ ๋“œ๋ก  5๋Œ€ ๊ตฐ์ง‘ ํŽธ๋Œ€ ๋น„ํ–‰ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ (Google Colab)\n", "### 5-Drone Swarm Formation Simulator (Three.js 3D & Python Backend)\n", "\n", "๋ณธ ๋…ธํŠธ๋ถ์€ Three.js 3D ๊ทธ๋ž˜ํ”ฝ ์—”์ง„๊ณผ Python ๋ฐฑ์—”๋“œ(Starlette + Uvicorn + WebSockets)๋กœ ๊ตฌ๋™๋˜๋Š” **๋“œ๋ก  5๋Œ€(๋ฆฌ๋” 1๋Œ€ + ์œ™๋งจ 4๋Œ€) ๊ตฐ์ง‘ ํŽธ๋Œ€ ๋น„ํ–‰ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ**๋ฅผ Google Colab ํ™˜๊ฒฝ์—์„œ ์›ํด๋ฆญ์œผ๋กœ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n", "\n", "---\n", "\n", "### ๐ŸŒŸ ์ฃผ์š” ๊ธฐ๋Šฅ\n", "- **5๋Œ€ ๊ตฐ์ง‘ ๋น„ํ–‰ ์ œ์–ด**: ๋ฆฌ๋” ๋“œ๋ก (VIPER-01) ๋งˆ์šฐ์Šค ์œ ๋„ ๋น„ํ–‰ ์‹œ 4๋Œ€์˜ ์œ™๋งจ ๋“œ๋ก (GHOST, PHANTOM, SPECTER, SHADOW)์ด ์‹ค์‹œ๊ฐ„ ํŽธ๋Œ€ ์œ ์ง€\n", "- **6๊ฐ€์ง€ ํŽธ๋Œ€ ๋ชจ๋“œ**: V ํŽธ๋Œ€, ๋‹ค์ด์•„๋ชฌ๋“œ, ํšก๋Œ€ ์ผ๋ ฌ, ์ข…๋Œ€ ์ง๋ ฌ, ๊ตฐ์ง‘ ์„ ํšŒ(Orbit), ์‚ฌ์„  ํŽธ๋Œ€\n", "- **์‹ค์‹œ๊ฐ„ 60Hz ํ…”๋ ˆ๋ฉ”ํŠธ๋ฆฌ**: WebSocket ์–‘๋ฐฉํ–ฅ ํ†ต์‹  ๊ธฐ๋ฐ˜ ์œ„์น˜, ์†๋„, ๊ณ ๋„, ๋ฐฐํ„ฐ๋ฆฌ, ๋ชจํ„ฐ RPM ๋™๊ธฐํ™”\n", "- **2D ์ „์ˆ  ๋ ˆ์ด๋” HUD & 3D ๋ ˆ์ด์ € ํŽธ๋Œ€ ๋งํฌ**: ๊ตฐ์ง‘ ๊ถค์  ํ‘œ์‹œ ๋ฐ ํŽธ๋Œ€ ๋ ˆ์ด์ € ์‹œ๊ฐํ™”\n", "- **์นด๋ฉ”๋ผ ๋ชจ๋“œ 4์ข…**: ๊ตฐ์ง‘ ๊ถค๋„๋ทฐ(1), ๋ฆฌ๋” ์ถ”์ ๋ทฐ(2), FPV ์กฐ์ข…์„(3), ํŽธ๋Œ€ ํ”„๋กœํ•„๋ทฐ(4)\n", "- **Web Audio ์‹ค์‹œ๊ฐ„ ์‚ฌ์šด๋“œ**: ๋กœํ„ฐ RPM ์—ฐ๋™ ์‚ฌ์šด๋“œ ์‹ ์‹œ์‚ฌ์ด์ €\n", "\n", "---" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1๋‹จ๊ณ„: ํ•„์ˆ˜ ํŒจํ‚ค์ง€ ๋ฐ ํ„ฐ๋„๋ง ๋„๊ตฌ ์„ค์น˜\n", "Starlette, Uvicorn, WebSockets ๋“ฑ ๋ฐฑ์—”๋“œ ๊ตฌ๋™ ํŒจํ‚ค์ง€์™€ Colab ์™ธ๋ถ€ ์ ‘์†์„ ์œ„ํ•œ Cloudflared๋ฅผ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# 1. ํ•„์ˆ˜ Python ํŒจํ‚ค์ง€ ์„ค์น˜\n", "!pip install -q starlette uvicorn websockets pyngrok nest_asyncio\n", "\n", "# 2. ๊ณ ์† ๋ฌด๋ฃŒ ํ„ฐ๋„ Cloudflared ๋ฐ”์ด๋„ˆ๋ฆฌ ๋‹ค์šด๋กœ๋“œ (ํ† ํฐ ์—†์ด ์™ธ๋ถ€ ์ ‘์† ๊ฐ€๋Šฅ)\n", "!wget -q -nc https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb\n", "!dpkg -i cloudflared-linux-amd64.deb > /dev/null 2>&1\n", "print(\"โœ… ํ™˜๊ฒฝ ์ค€๋น„ ์™„๋ฃŒ!\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2๋‹จ๊ณ„: ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์†Œ์Šค ์ฝ”๋“œ ์ƒ์„ฑ (๋‹จ๋… ์‹คํ–‰ ์ง€์›)\n", "Colab์— ํ•„์š”ํ•œ ๋ชจ๋“  ๋ฐฑ์—”๋“œ ๋ฐ ์›น ํ”„๋ก ํŠธ์—”๋“œ ํŒŒ์ผ(`app.py`, `static/...`)์„ ์ž๋™์œผ๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import os\n", "\n", "# ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ\n", "os.makedirs(\"static/css\", exist_ok=True)\n", "os.makedirs(\"static/js\", exist_ok=True)\n", "\n", "# app.py ์ž‘์„ฑ\n", "with open(\"app.py\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''\"\"\"\nDrone Formation Flight Simulation Server\nReal-time 6-DOF physics and 5-Drone Swarm Formation calculation backend with WebSockets & Starlette.\n\"\"\"\n\nimport asyncio\nimport json\nimport math\nimport time\nimport os\nimport contextlib\nimport sys\nfrom typing import Dict, Any, List\nimport uvicorn\nfrom starlette.applications import Starlette\nfrom starlette.routing import Route, WebSocketRoute, Mount\nfrom starlette.staticfiles import StaticFiles\nfrom starlette.responses import FileResponse, JSONResponse\nfrom starlette.websockets import WebSocket, WebSocketDisconnect\n\n# Directory setup\nBASE_DIR = os.path.dirname(os.path.abspath(__file__))\nSTATIC_DIR = os.path.join(BASE_DIR, \"static\")\n\nos.makedirs(STATIC_DIR, exist_ok=True)\nos.makedirs(os.path.join(STATIC_DIR, \"css\"), exist_ok=True)\nos.makedirs(os.path.join(STATIC_DIR, \"js\"), exist_ok=True)\n\n\nclass Vector3:\n def __init__(self, x=0.0, y=0.0, z=0.0):\n self.x = float(x)\n self.y = float(y)\n self.z = float(z)\n\n def to_dict(self):\n return {\"x\": round(self.x, 3), \"y\": round(self.y, 3), \"z\": round(self.z, 3)}\n\n def dist_to(self, other: 'Vector3') -> float:\n return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2 + (self.z - other.z)**2)\n\n\nclass DroneState:\n def __init__(self, drone_id: str, name: str, callsign: str, initial_pos: Vector3, color: str, is_leader: bool = False):\n self.id = drone_id\n self.name = name\n self.callsign = callsign\n self.pos = initial_pos\n self.target_pos = Vector3(initial_pos.x, initial_pos.y, initial_pos.z)\n self.vel = Vector3(0.0, 0.0, 0.0)\n self.pitch = 0.0 # radians\n self.roll = 0.0 # radians\n self.yaw = 0.0 # radians\n self.rotor_rpm = [5200.0, 5200.0, 5200.0, 5200.0]\n self.battery = 98.5 # percentage\n self.armed = True\n self.color = color\n self.is_leader = is_leader\n self.status = \"AIRBORNE - FORMATION OK\"\n\n def to_dict(self) -> Dict[str, Any]:\n return {\n \"id\": self.id,\n \"name\": self.name,\n \"callsign\": self.callsign,\n \"position\": self.pos.to_dict(),\n \"target\": self.target_pos.to_dict(),\n \"velocity\": self.vel.to_dict(),\n \"speed\": round(math.sqrt(self.vel.x**2 + self.vel.y**2 + self.vel.z**2) * 3.6, 1), # km/h\n \"rotation\": {\n \"pitch\": round(math.degrees(self.pitch), 1),\n \"roll\": round(math.degrees(self.roll), 1),\n \"yaw\": round(math.degrees(self.yaw), 1)\n },\n \"rotors\": [round(r, 0) for r in self.rotor_rpm],\n \"battery\": round(self.battery, 1),\n \"armed\": self.armed,\n \"color\": self.color,\n \"is_leader\": self.is_leader,\n \"status\": self.status\n }\n\n\nclass FormationEngine:\n \"\"\"Manages 5-Drone swarm flight physics and dynamic formation geometry\"\"\"\n def __init__(self):\n # 5 Drones Configuration\n self.drones: List[DroneState] = [\n DroneState(\"alpha_1\", \"Leader Drone\", \"VIPER-01\", Vector3(0.0, 8.0, 0.0), \"#00f0ff\", is_leader=True),\n DroneState(\"alpha_2\", \"Wingman 1 (R1)\", \"GHOST-02\", Vector3(5.0, 8.0, -5.0), \"#ff0055\"),\n DroneState(\"alpha_3\", \"Wingman 2 (L1)\", \"PHANTOM-03\", Vector3(-5.0, 8.0, -5.0), \"#00ff88\"),\n DroneState(\"alpha_4\", \"Wingman 3 (R2)\", \"SPECTER-04\", Vector3(10.0, 8.0, -10.0),\"#ffb700\"),\n DroneState(\"alpha_5\", \"Wingman 4 (L2)\", \"SHADOW-05\", Vector3(-10.0, 8.0,-10.0),\"#b026ff\")\n ]\n\n self.leader = self.drones[0]\n self.formation_mode = \"V_FORMATION\" # V_FORMATION, SIDE_BY_SIDE, TANDEM, DIAMOND, ORBIT, VERTICAL, ECHELON\n self.formation_distance = 6.0\n self.formation_height_offset = 0.0\n self.speed_multiplier = 1.0\n self.autonomous_mode = False\n self.auto_pattern = \"FIGURE_EIGHT\" # FIGURE_EIGHT, CIRCLE, WAYPOINT_PATROL\n\n self.orbit_angle = 0.0\n self.sim_time = 0.0\n self.clients: List[WebSocket] = []\n\n def set_target(self, x: float, y: float, z: float):\n \"\"\"Set manual flight target for the leader drone\"\"\"\n self.leader.target_pos.x = max(-80.0, min(80.0, float(x)))\n self.leader.target_pos.y = max(1.5, min(40.0, float(y)))\n self.leader.target_pos.z = max(-80.0, min(80.0, float(z)))\n\n def calculate_follower_targets(self) -> List[Vector3]:\n \"\"\"Calculates target 3D coordinates for all 4 wingmen based on formation geometry and leader state\"\"\"\n lx, ly, lz = self.leader.pos.x, self.leader.pos.y, self.leader.pos.z\n yaw = self.leader.yaw\n dist = self.formation_distance\n h_off = self.formation_height_offset\n\n # Unit directional vectors based on leader yaw\n # Forward vector\n fx = math.sin(yaw)\n fz = math.cos(yaw)\n # Right vector\n rx = math.cos(yaw)\n rz = -math.sin(yaw)\n\n targets = []\n\n if self.formation_mode == \"V_FORMATION\":\n # Classic 5-Drone V-Shape\n offsets = [\n # (Right factor, Forward factor, Height factor)\n (1.0, -1.0, 0.0), # Drone 2: Starboard Inner\n (-1.0, -1.0, 0.0), # Drone 3: Port Inner\n (2.0, -2.0, 0.5), # Drone 4: Starboard Outer\n (-2.0, -2.0, 0.5), # Drone 5: Port Outer\n ]\n for r_fac, f_fac, h_fac in offsets:\n tx = lx + (rx * r_fac + fx * f_fac) * dist\n tz = lz + (rz * r_fac + fz * f_fac) * dist\n ty = ly + h_off + (h_fac * h_off)\n targets.append(Vector3(tx, ty, tz))\n\n elif self.formation_mode == \"SIDE_BY_SIDE\":\n # 5-Drone Wide Line Abreast: L2, L1, Leader, R1, R2\n offsets = [\n (1.0, 0.0), # D2: Right 1\n (-1.0, 0.0), # D3: Left 1\n (2.0, 0.0), # D4: Right 2\n (-2.0, 0.0), # D5: Left 2\n ]\n for r_fac, f_fac in offsets:\n tx = lx + (rx * r_fac) * dist\n tz = lz + (rz * r_fac) * dist\n targets.append(Vector3(tx, ly + h_off, tz))\n\n elif self.formation_mode == \"TANDEM\":\n # 5-Drone Trail / Single Column In-Line behind Leader\n offsets = [-1.0, -2.0, -3.0, -4.0]\n for i, f_fac in enumerate(offsets):\n tx = lx + (fx * f_fac) * dist\n tz = lz + (fz * f_fac) * dist\n ty = ly + (h_off * (i + 1) * 0.3)\n targets.append(Vector3(tx, ty, tz))\n\n elif self.formation_mode == \"DIAMOND\":\n # 5-Drone Diamond / Arrowhead formation\n offsets = [\n (1.2, -0.8, 0.0), # D2: Right Wing\n (-1.2, -0.8, 0.0), # D3: Left Wing\n (0.0, -1.6, 0.0), # D4: Center Rear\n (0.0, -2.6, 0.5), # D5: Tail High-Guard\n ]\n for r_fac, f_fac, h_fac in offsets:\n tx = lx + (rx * r_fac + fx * f_fac) * dist\n tz = lz + (rz * r_fac + fz * f_fac) * dist\n ty = ly + h_off + (h_fac * 1.5)\n targets.append(Vector3(tx, ty, tz))\n\n elif self.formation_mode == \"ORBIT\":\n # 4 Wingmen dynamically orbiting around Leader at 90-degree spacing\n for i in range(4):\n angle = self.orbit_angle + (i * math.pi * 0.5)\n ox = math.cos(angle) * dist\n oz = math.sin(angle) * dist\n oy = math.sin(angle * 2.0) * 1.5\n targets.append(Vector3(lx + ox, ly + h_off + oy, lz + oz))\n\n elif self.formation_mode == \"ECHELON_RIGHT\":\n # Echelon Right (45 deg diagonal trail to the right)\n for i in range(1, 5):\n tx = lx + (rx * i - fx * i) * (dist * 0.75)\n tz = lz + (rz * i - fz * i) * (dist * 0.75)\n targets.append(Vector3(tx, ly + h_off + (i * 0.4), tz))\n\n elif self.formation_mode == \"VERTICAL\":\n # 3D Stack / Ladder\n offsets = [\n (0.0, 0.0, 1.0), # D2: Top +1\n (0.0, 0.0, -1.0), # D3: Bottom -1\n (0.5, -0.5, 2.0), # D4: High Rear\n (-0.5, -0.5, -2.0) # D5: Low Rear\n ]\n for r_fac, f_fac, h_fac in offsets:\n tx = lx + (rx * r_fac + fx * f_fac) * (dist * 0.6)\n tz = lz + (rz * r_fac + fz * f_fac) * (dist * 0.6)\n ty = ly + (h_fac * dist * 0.6) + h_off\n targets.append(Vector3(tx, ty, tz))\n\n else:\n # Fallback\n for i in range(1, 5):\n targets.append(Vector3(lx + i * 4.0, ly, lz))\n\n return targets\n\n def update_physics(self, dt: float):\n \"\"\"Simulate realistic 6-DOF drone dynamics for all 5 drones\"\"\"\n self.sim_time += dt\n self.orbit_angle += dt * 1.2\n\n # Autonomous flight patterns if enabled\n if self.autonomous_mode:\n t = self.sim_time * 0.35 * self.speed_multiplier\n if self.auto_pattern == \"FIGURE_EIGHT\":\n self.leader.target_pos.x = math.sin(t) * 35.0\n self.leader.target_pos.z = math.sin(t * 2.0) * 20.0\n self.leader.target_pos.y = 8.0 + math.sin(t * 3.0) * 3.0\n elif self.auto_pattern == \"CIRCLE\":\n self.leader.target_pos.x = math.cos(t) * 30.0\n self.leader.target_pos.z = math.sin(t) * 30.0\n self.leader.target_pos.y = 10.0 + math.cos(t * 2.0) * 2.5\n elif self.auto_pattern == \"WAYPOINT_PATROL\":\n wps = [Vector3(-25, 8, -25), Vector3(25, 12, -25), Vector3(25, 8, 25), Vector3(-25, 14, 25)]\n idx = int((t * 0.5) % 4)\n next_idx = (idx + 1) % 4\n blend = (t * 0.5) % 1.0\n p1 = wps[idx]\n p2 = wps[next_idx]\n self.leader.target_pos.x = p1.x + (p2.x - p1.x) * blend\n self.leader.target_pos.y = p1.y + (p2.y - p1.y) * blend\n self.leader.target_pos.z = p1.z + (p2.z - p1.z) * blend\n\n # 1. Simulate Leader Drone (Moves toward target)\n self._simulate_drone(self.leader, self.leader.target_pos, dt, responsiveness=3.2 * self.speed_multiplier)\n\n # 2. Simulate 4 Follower Drones\n follower_targets = self.calculate_follower_targets()\n for idx, target in enumerate(follower_targets):\n drone = self.drones[idx + 1]\n drone.target_pos = target\n self._simulate_drone(drone, target, dt, responsiveness=3.6 * self.speed_multiplier)\n\n # Sync status\n err = drone.pos.dist_to(target)\n if err < 0.6:\n drone.status = \"FORMATION LOCKED\"\n elif err < 2.5:\n drone.status = \"TRACKING\"\n else:\n drone.status = \"REJOINING\"\n\n # Battery consumption\n drone.battery = max(5.0, drone.battery - dt * 0.012)\n\n self.leader.battery = max(5.0, self.leader.battery - dt * 0.012)\n\n def _simulate_drone(self, drone: DroneState, target: Vector3, dt: float, responsiveness: float):\n \"\"\"Critically damped spring-damper controller for drone navigation + attitude banking\"\"\"\n ex = target.x - drone.pos.x\n ey = target.y - drone.pos.y\n ez = target.z - drone.pos.z\n\n k_p = responsiveness * 3.5\n k_d = 2.4 * math.sqrt(k_p)\n\n acc_x = (ex * k_p) - (drone.vel.x * k_d)\n acc_y = (ey * (k_p * 1.2)) - (drone.vel.y * k_d)\n acc_z = (ez * k_p) - (drone.vel.z * k_d)\n\n max_acc = 22.0\n acc_mag = math.sqrt(acc_x**2 + acc_y**2 + acc_z**2)\n if acc_mag > max_acc:\n acc_x = (acc_x / acc_mag) * max_acc\n acc_y = (acc_y / acc_mag) * max_acc\n acc_z = (acc_z / acc_mag) * max_acc\n\n drone.vel.x += acc_x * dt\n drone.vel.y += acc_y * dt\n drone.vel.z += acc_z * dt\n\n drone.pos.x += drone.vel.x * dt\n drone.pos.y += drone.vel.y * dt\n drone.pos.z += drone.vel.z * dt\n\n # Yaw alignment towards velocity\n horizontal_speed = math.sqrt(drone.vel.x**2 + drone.vel.z**2)\n if horizontal_speed > 0.3:\n desired_yaw = math.atan2(drone.vel.x, drone.vel.z)\n diff = (desired_yaw - drone.yaw + math.pi) % (2 * math.pi) - math.pi\n drone.yaw += diff * min(1.0, dt * 4.0)\n\n # Body-frame pitch & roll\n cos_y = math.cos(drone.yaw)\n sin_y = math.sin(drone.yaw)\n forward_acc = acc_z * cos_y + acc_x * sin_y\n lateral_acc = acc_x * cos_y - acc_z * sin_y\n\n desired_pitch = -math.atan2(forward_acc, 9.81) * 0.7\n desired_roll = math.atan2(lateral_acc, 9.81) * 0.7\n\n max_tilt = math.radians(35.0)\n desired_pitch = max(-max_tilt, min(max_tilt, desired_pitch))\n desired_roll = max(-max_tilt, min(max_tilt, desired_roll))\n\n drone.pitch += (desired_pitch - drone.pitch) * min(1.0, dt * 7.0)\n drone.roll += (desired_roll - drone.roll) * min(1.0, dt * 7.0)\n\n base_rpm = 5000.0 + (acc_y * 120.0)\n tilt_factor = math.sqrt(drone.pitch**2 + drone.roll**2) * 1500.0\n drone.rotor_rpm[0] = max(1000.0, base_rpm + tilt_factor + drone.roll * 800.0)\n drone.rotor_rpm[1] = max(1000.0, base_rpm + tilt_factor - drone.roll * 800.0)\n drone.rotor_rpm[2] = max(1000.0, base_rpm + tilt_factor + drone.pitch * 800.0)\n drone.rotor_rpm[3] = max(1000.0, base_rpm + tilt_factor - drone.pitch * 800.0)\n\n def get_state_payload(self) -> Dict[str, Any]:\n return {\n \"timestamp\": time.time(),\n \"count\": len(self.drones),\n \"leader\": self.leader.to_dict(),\n \"drones\": [d.to_dict() for d in self.drones],\n \"formation\": {\n \"mode\": self.formation_mode,\n \"distance\": round(self.formation_distance, 2),\n \"target_distance\": self.formation_distance,\n \"height_offset\": self.formation_height_offset,\n \"autonomous\": self.autonomous_mode,\n \"auto_pattern\": self.auto_pattern,\n \"speed_multiplier\": round(self.speed_multiplier, 2)\n }\n }\n\n\nengine = FormationEngine()\n\n\n# Starlette App Endpoints\nasync def homepage(request):\n index_file = os.path.join(STATIC_DIR, \"index.html\")\n return FileResponse(index_file)\n\n\nasync def api_status(request):\n return JSONResponse(engine.get_state_payload())\n\n\nasync def websocket_endpoint(websocket: WebSocket):\n await websocket.accept()\n engine.clients.append(websocket)\n try:\n while True:\n data = await websocket.receive_text()\n try:\n msg = json.loads(data)\n cmd_type = msg.get(\"type\")\n\n if cmd_type == \"SET_TARGET\":\n x = msg.get(\"x\", 0.0)\n y = msg.get(\"y\", 8.0)\n z = msg.get(\"z\", 0.0)\n engine.set_target(x, y, z)\n\n elif cmd_type == \"SET_FORMATION\":\n if \"mode\" in msg:\n engine.formation_mode = msg[\"mode\"]\n if \"distance\" in msg:\n engine.formation_distance = float(msg[\"distance\"])\n if \"height_offset\" in msg:\n engine.formation_height_offset = float(msg[\"height_offset\"])\n\n elif cmd_type == \"SET_AUTONOMOUS\":\n engine.autonomous_mode = bool(msg.get(\"enabled\", False))\n if \"pattern\" in msg:\n engine.auto_pattern = msg[\"pattern\"]\n\n elif cmd_type == \"SET_SPEED\":\n engine.speed_multiplier = float(msg.get(\"speed\", 1.0))\n\n elif cmd_type == \"RESET_POSITION\":\n engine.leader.pos = Vector3(0.0, 8.0, 0.0)\n engine.leader.target_pos = Vector3(0.0, 8.0, 0.0)\n engine.leader.vel = Vector3(0.0, 0.0, 0.0)\n # Reset wingmen around leader\n for idx, d in enumerate(engine.drones[1:], 1):\n d.pos = Vector3(idx * 4.0, 8.0, -idx * 3.0)\n d.vel = Vector3(0.0, 0.0, 0.0)\n\n except json.JSONDecodeError:\n pass\n except WebSocketDisconnect:\n if websocket in engine.clients:\n engine.clients.remove(websocket)\n except Exception as e:\n if websocket in engine.clients:\n engine.clients.remove(websocket)\n\n\nasync def physics_loop():\n \"\"\"High-frequency background simulation loop (60 FPS)\"\"\"\n last_time = time.time()\n while True:\n now = time.time()\n dt = min(0.05, now - last_time)\n last_time = now\n\n engine.update_physics(dt)\n\n if engine.clients:\n payload = json.dumps(engine.get_state_payload())\n dead_clients = []\n for client in engine.clients:\n try:\n await client.send_text(payload)\n except Exception:\n dead_clients.append(client)\n for dead in dead_clients:\n if dead in engine.clients:\n engine.clients.remove(dead)\n\n await asyncio.sleep(1.0 / 60.0)\n\n\n@contextlib.asynccontextmanager\nasync def lifespan(app):\n sim_task = asyncio.create_task(physics_loop())\n yield\n sim_task.cancel()\n try:\n await sim_task\n except asyncio.CancelledError:\n pass\n\n\nroutes = [\n Route(\"/\", homepage),\n WebSocketRoute(\"/ws\", websocket_endpoint),\n Route(\"/api/status\", api_status),\n Mount(\"/static\", StaticFiles(directory=STATIC_DIR), name=\"static\")\n]\n\napp = Starlette(debug=True, routes=routes, lifespan=lifespan)\n\n\nif __name__ == \"__main__\":\n if hasattr(sys.stdout, \"reconfigure\"):\n try:\n sys.stdout.reconfigure(encoding=\"utf-8\")\n except Exception:\n pass\n print(\"================================================================\")\n print(\" [5-DRONE FORMATION FLIGHT SIMULATOR] Three.js + Python\")\n print(\" Server starting at: http://127.0.0.1:8000\")\n print(\"================================================================\")\n uvicorn.run(\"app:app\", host=\"127.0.0.1\", port=8000, log_level=\"info\", reload=False)\n''')\n", "\n", "# static/index.html ์ž‘์„ฑ\n", "with open(\"static/index.html\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''\n\n\n \n \n ๋“œ๋ก  5๋Œ€ ๊ตฐ์ง‘ ํŽธ๋Œ€ ๋น„ํ–‰ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ | 5-Drone Swarm Formation (Three.js & Python)\n \n \n \n \n\n \n \n\n\n\n \n
\n
\n\n \n
\n
\n
\n

AERO-SWARM TACTICAL HUD

\n 5-DRONE SWARM FORMATION CONTROLLER\n
\n\n
\n
\n
\n CONNECTING...\n
\n
\n SWARM:\n 5 UNITS ACTIVE\n
\n
\n
\n\n \n \n\n \n \n\n \n \n\n \n \n \n \n \n\n\n''')\n", "\n", "# static/css/style.css ์ž‘์„ฑ\n", "with open(\"static/css/style.css\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''/* ============================================================\n DRONE FORMATION FLIGHT SIMULATOR - TACTICAL HUD STYLESHEET\n ============================================================ */\n\n@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap');\n\n:root {\n --bg-dark: #070a0f;\n --panel-bg: rgba(12, 18, 28, 0.72);\n --panel-border: rgba(0, 240, 255, 0.28);\n --panel-border-glow: rgba(0, 240, 255, 0.45);\n \n --color-cyan: #00f0ff;\n --color-pink: #ff0055;\n --color-green: #00ff88;\n --color-amber: #ffb700;\n --color-text-main: #d8e5f2;\n --color-text-dim: #7a8fa6;\n \n --font-head: 'Orbitron', sans-serif;\n --font-ui: 'Rajdhani', sans-serif;\n --font-mono: 'Share Tech Mono', monospace;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n user-select: none;\n}\n\nbody {\n background-color: var(--bg-dark);\n color: var(--color-text-main);\n font-family: var(--font-ui);\n overflow: hidden;\n width: 100vw;\n height: 100vh;\n}\n\n#canvas-container {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 1;\n}\n\n/* Vignette & scanline overlay */\n.hud-overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n z-index: 2;\n background: radial-gradient(circle at center, transparent 60%, rgba(3, 7, 12, 0.75) 100%);\n box-shadow: inset 0 0 100px rgba(0, 240, 255, 0.05);\n}\n\n.hud-overlay::before {\n content: \" \";\n position: absolute;\n top: 0; left: 0; width: 100%; height: 100%;\n background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%);\n background-size: 100% 4px;\n opacity: 0.2;\n pointer-events: none;\n}\n\n/* ================= HUD TOP HEADER ================= */\n.top-nav {\n position: absolute;\n top: 14px;\n left: 20px;\n right: 20px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n z-index: 10;\n pointer-events: auto;\n}\n\n.brand-title {\n display: flex;\n align-items: center;\n gap: 12px;\n background: var(--panel-bg);\n border: 1px solid var(--panel-border);\n backdrop-filter: blur(14px);\n padding: 8px 18px;\n border-radius: 6px;\n box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), inset 0 0 10px rgba(0, 240, 255, 0.1);\n}\n\n.brand-icon {\n width: 12px;\n height: 12px;\n background: var(--color-cyan);\n box-shadow: 0 0 12px var(--color-cyan);\n border-radius: 2px;\n animation: pulseGlow 2s infinite alternate;\n}\n\n.brand-title h1 {\n font-family: var(--font-head);\n font-size: 1.05rem;\n font-weight: 800;\n letter-spacing: 2px;\n color: #fff;\n text-transform: uppercase;\n}\n\n.brand-title span {\n color: var(--color-cyan);\n font-size: 0.8rem;\n font-family: var(--font-mono);\n border-left: 1px solid var(--color-text-dim);\n padding-left: 10px;\n}\n\n.top-status-bar {\n display: flex;\n gap: 12px;\n}\n\n.status-badge {\n background: var(--panel-bg);\n border: 1px solid var(--panel-border);\n backdrop-filter: blur(14px);\n padding: 6px 14px;\n border-radius: 4px;\n font-family: var(--font-mono);\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.status-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--color-green);\n box-shadow: 0 0 8px var(--color-green);\n}\n\n.status-dot.warning {\n background: var(--color-amber);\n box-shadow: 0 0 8px var(--color-amber);\n}\n\n/* ================= MAIN SIDE PANELS ================= */\n.side-panel {\n position: absolute;\n top: 72px;\n bottom: 86px;\n width: 320px;\n background: var(--panel-bg);\n border: 1px solid var(--panel-border);\n backdrop-filter: blur(16px);\n border-radius: 8px;\n padding: 16px;\n z-index: 10;\n display: flex;\n flex-direction: column;\n gap: 14px;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);\n overflow-y: auto;\n scrollbar-width: thin;\n scrollbar-color: var(--color-cyan) transparent;\n}\n\n.side-panel::-webkit-scrollbar {\n width: 4px;\n}\n.side-panel::-webkit-scrollbar-thumb {\n background: var(--color-cyan);\n border-radius: 4px;\n}\n\n.left-panel {\n left: 20px;\n}\n\n.right-panel {\n right: 20px;\n}\n\n.panel-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n border-bottom: 1px solid rgba(0, 240, 255, 0.2);\n padding-bottom: 8px;\n}\n\n.panel-title {\n font-family: var(--font-head);\n font-size: 0.88rem;\n letter-spacing: 1.5px;\n color: var(--color-cyan);\n text-transform: uppercase;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n/* Drone Telemetry Cards */\n.drone-card {\n background: rgba(18, 25, 38, 0.7);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 6px;\n padding: 8px 10px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n transition: all 0.3s ease;\n}\n\n.drone-card.leader {\n border-left: 4px solid var(--color-cyan);\n}\n\n.card-top {\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.drone-name {\n font-family: var(--font-head);\n font-weight: 700;\n font-size: 0.82rem;\n letter-spacing: 0.5px;\n}\n\n.callsign-tag {\n font-family: var(--font-mono);\n font-size: 0.7rem;\n padding: 1px 5px;\n border-radius: 3px;\n background: rgba(0, 0, 0, 0.4);\n border: 1px solid rgba(255, 255, 255, 0.15);\n}\n\n.telemetry-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 6px;\n}\n\n.tele-item {\n display: flex;\n flex-direction: column;\n background: rgba(7, 12, 18, 0.5);\n padding: 4px 6px;\n border-radius: 4px;\n border: 1px solid rgba(255, 255, 255, 0.04);\n}\n\n.tele-label {\n font-size: 0.65rem;\n color: var(--color-text-dim);\n font-family: var(--font-mono);\n text-transform: uppercase;\n}\n\n.tele-val {\n font-family: var(--font-mono);\n font-size: 0.8rem;\n font-weight: 700;\n color: #ffffff;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n\n.progress-bar-bg {\n width: 100%;\n height: 5px;\n background: rgba(255, 255, 255, 0.1);\n border-radius: 3px;\n overflow: hidden;\n margin-top: 4px;\n}\n\n.progress-bar-fill {\n height: 100%;\n width: 90%;\n background: var(--color-green);\n border-radius: 3px;\n transition: width 0.3s ease;\n}\n\n/* Formation Distance Gauge */\n.formation-gauge-card {\n background: rgba(18, 25, 38, 0.7);\n border: 1px solid rgba(0, 240, 255, 0.2);\n border-radius: 6px;\n padding: 12px;\n text-align: center;\n}\n\n.gauge-title {\n font-family: var(--font-head);\n font-size: 0.78rem;\n letter-spacing: 1px;\n color: var(--color-text-dim);\n margin-bottom: 6px;\n}\n\n.gauge-distance {\n font-family: var(--font-mono);\n font-size: 1.6rem;\n font-weight: 900;\n color: var(--color-cyan);\n text-shadow: 0 0 10px rgba(0, 240, 255, 0.5);\n}\n\n.formation-status-text {\n font-family: var(--font-mono);\n font-size: 0.8rem;\n color: var(--color-green);\n margin-top: 4px;\n}\n\n/* ================= RIGHT PANEL CONTROLS ================= */\n.radar-box {\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba(7, 11, 18, 0.6);\n border: 1px solid rgba(0, 240, 255, 0.2);\n border-radius: 6px;\n padding: 8px;\n}\n\n#tactical-radar {\n display: block;\n border-radius: 50%;\n}\n\n.control-group {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.control-label {\n font-family: var(--font-head);\n font-size: 0.75rem;\n letter-spacing: 1px;\n color: var(--color-text-dim);\n text-transform: uppercase;\n}\n\n.formation-buttons {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 6px;\n}\n\n.btn-formation {\n background: rgba(18, 25, 38, 0.8);\n border: 1px solid rgba(0, 240, 255, 0.2);\n color: var(--color-text-main);\n padding: 7px 4px;\n border-radius: 4px;\n font-family: var(--font-ui);\n font-size: 0.82rem;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.2s ease;\n text-align: center;\n}\n\n.btn-formation:hover {\n background: rgba(0, 240, 255, 0.15);\n border-color: var(--color-cyan);\n color: #fff;\n}\n\n.btn-formation.active {\n background: var(--color-cyan);\n border-color: var(--color-cyan);\n color: #050b14;\n font-weight: 700;\n box-shadow: 0 0 12px rgba(0, 240, 255, 0.4);\n}\n\n.slider-row {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.slider-info {\n display: flex;\n justify-content: space-between;\n font-family: var(--font-mono);\n font-size: 0.78rem;\n}\n\ninput[type=\"range\"] {\n -webkit-appearance: none;\n width: 100%;\n height: 5px;\n background: rgba(255, 255, 255, 0.15);\n border-radius: 3px;\n outline: none;\n}\n\ninput[type=\"range\"]::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: var(--color-cyan);\n cursor: pointer;\n box-shadow: 0 0 8px var(--color-cyan);\n}\n\n.toggle-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n background: rgba(18, 25, 38, 0.6);\n padding: 8px 12px;\n border-radius: 4px;\n border: 1px solid rgba(255, 255, 255, 0.06);\n}\n\n.switch {\n position: relative;\n display: inline-block;\n width: 44px;\n height: 22px;\n}\n\n.switch input { opacity: 0; width: 0; height: 0; }\n\n.slider-toggle {\n position: absolute;\n cursor: pointer;\n top: 0; left: 0; right: 0; bottom: 0;\n background-color: rgba(255, 255, 255, 0.2);\n transition: .3s;\n border-radius: 22px;\n}\n\n.slider-toggle:before {\n position: absolute;\n content: \"\";\n height: 16px;\n width: 16px;\n left: 3px;\n bottom: 3px;\n background-color: white;\n transition: .3s;\n border-radius: 50%;\n}\n\ninput:checked + .slider-toggle {\n background-color: var(--color-cyan);\n}\n\ninput:checked + .slider-toggle:before {\n transform: translateX(22px);\n background-color: #070a0f;\n}\n\n/* ================= BOTTOM FLIGHT CONTROL BAR ================= */\n.bottom-bar {\n position: absolute;\n bottom: 16px;\n left: 20px;\n right: 20px;\n background: var(--panel-bg);\n border: 1px solid var(--panel-border);\n backdrop-filter: blur(16px);\n border-radius: 8px;\n padding: 10px 20px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n z-index: 10;\n box-shadow: 0 4px 25px rgba(0, 0, 0, 0.6);\n}\n\n.flight-instruct {\n display: flex;\n align-items: center;\n gap: 16px;\n font-family: var(--font-mono);\n font-size: 0.85rem;\n}\n\n.instruct-pill {\n background: rgba(0, 240, 255, 0.1);\n border: 1px solid rgba(0, 240, 255, 0.3);\n padding: 4px 10px;\n border-radius: 4px;\n color: var(--color-cyan);\n font-weight: bold;\n}\n\n.cam-buttons {\n display: flex;\n gap: 8px;\n}\n\n.btn-cam {\n background: rgba(18, 25, 38, 0.8);\n border: 1px solid rgba(255, 255, 255, 0.15);\n color: var(--color-text-main);\n padding: 6px 12px;\n border-radius: 4px;\n font-family: var(--font-head);\n font-size: 0.72rem;\n font-weight: 600;\n letter-spacing: 0.5px;\n cursor: pointer;\n transition: all 0.2s ease;\n}\n\n.btn-cam:hover {\n border-color: var(--color-cyan);\n color: #fff;\n}\n\n.btn-cam.active {\n background: var(--color-cyan);\n border-color: var(--color-cyan);\n color: #050b14;\n font-weight: 700;\n box-shadow: 0 0 10px rgba(0, 240, 255, 0.4);\n}\n\n.action-buttons {\n display: flex;\n gap: 8px;\n}\n\n.btn-action {\n background: rgba(255, 0, 85, 0.15);\n border: 1px solid rgba(255, 0, 85, 0.4);\n color: #ff5588;\n padding: 6px 14px;\n border-radius: 4px;\n font-family: var(--font-head);\n font-size: 0.75rem;\n font-weight: 700;\n cursor: pointer;\n transition: all 0.2s;\n}\n\n.btn-action:hover {\n background: rgba(255, 0, 85, 0.3);\n color: #fff;\n box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);\n}\n\n/* Mouse targeting 3D reticle indicator */\n.target-crosshair {\n position: absolute;\n pointer-events: none;\n transform: translate(-50%, -50%);\n width: 32px;\n height: 32px;\n border: 1px dashed var(--color-cyan);\n border-radius: 50%;\n z-index: 5;\n opacity: 0.8;\n transition: width 0.15s, height 0.15s;\n}\n\n.target-crosshair::before, .target-crosshair::after {\n content: \"\";\n position: absolute;\n background: var(--color-cyan);\n}\n.target-crosshair::before { top: 50%; left: -6px; right: -6px; height: 1px; transform: translateY(-50%); }\n.target-crosshair::after { left: 50%; top: -6px; bottom: -6px; width: 1px; transform: translateX(-50%); }\n\n@keyframes pulseGlow {\n from { box-shadow: 0 0 6px var(--color-cyan); }\n to { box-shadow: 0 0 18px var(--color-cyan); }\n}\n\n@media (max-width: 1024px) {\n .side-panel { width: 260px; }\n .flight-instruct { display: none; }\n}\n''')\n", "\n", "# static/js/audio.js ์ž‘์„ฑ\n", "with open(\"static/js/audio.js\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''/**\n * Synthesizer for Drone Rotor Hum and Tactical HUD Audio FX\n * Uses Web Audio API without external audio file dependencies\n */\nclass DroneAudioSystem {\n constructor() {\n this.ctx = null;\n this.enabled = false;\n this.leaderGain = null;\n this.followerGain = null;\n this.leaderOsc1 = null;\n this.leaderOsc2 = null;\n this.followerOsc1 = null;\n this.followerOsc2 = null;\n }\n\n init() {\n if (this.ctx) return;\n try {\n const AudioContext = window.AudioContext || window.webkitAudioContext;\n this.ctx = new AudioContext();\n\n // Master compressor to prevent clipping\n const compressor = this.ctx.createDynamicsCompressor();\n compressor.threshold.setValueAtTime(-18, this.ctx.currentTime);\n compressor.knee.setValueAtTime(30, this.ctx.currentTime);\n compressor.ratio.setValueAtTime(12, this.ctx.currentTime);\n compressor.attack.setValueAtTime(0.003, this.ctx.currentTime);\n compressor.release.setValueAtTime(0.25, this.ctx.currentTime);\n compressor.connect(this.ctx.destination);\n\n this.masterGain = this.ctx.createGain();\n this.masterGain.gain.setValueAtTime(0.2, this.ctx.currentTime);\n this.masterGain.connect(compressor);\n\n // Leader Drone sound synthesizer (Dual harmonic oscillator + lowpass filter)\n this.leaderFilter = this.ctx.createBiquadFilter();\n this.leaderFilter.type = \"lowpass\";\n this.leaderFilter.frequency.setValueAtTime(650, this.ctx.currentTime);\n\n this.leaderGain = this.ctx.createGain();\n this.leaderGain.gain.setValueAtTime(0.0, this.ctx.currentTime);\n this.leaderGain.connect(this.leaderFilter);\n this.leaderFilter.connect(this.masterGain);\n\n this.leaderOsc1 = this.ctx.createOscillator();\n this.leaderOsc1.type = \"sawtooth\";\n this.leaderOsc1.frequency.setValueAtTime(95, this.ctx.currentTime);\n\n this.leaderOsc2 = this.ctx.createOscillator();\n this.leaderOsc2.type = \"triangle\";\n this.leaderOsc2.frequency.setValueAtTime(190, this.ctx.currentTime);\n\n this.leaderOsc1.connect(this.leaderGain);\n this.leaderOsc2.connect(this.leaderGain);\n this.leaderOsc1.start();\n this.leaderOsc2.start();\n\n // Follower Drone sound synthesizer\n this.followerFilter = this.ctx.createBiquadFilter();\n this.followerFilter.type = \"lowpass\";\n this.followerFilter.frequency.setValueAtTime(600, this.ctx.currentTime);\n\n this.followerGain = this.ctx.createGain();\n this.followerGain.gain.setValueAtTime(0.0, this.ctx.currentTime);\n this.followerGain.connect(this.followerFilter);\n this.followerFilter.connect(this.masterGain);\n\n this.followerOsc1 = this.ctx.createOscillator();\n this.followerOsc1.type = \"sawtooth\";\n this.followerOsc1.frequency.setValueAtTime(98, this.ctx.currentTime);\n\n this.followerOsc2 = this.ctx.createOscillator();\n this.followerOsc2.type = \"triangle\";\n this.followerOsc2.frequency.setValueAtTime(196, this.ctx.currentTime);\n\n this.followerOsc1.connect(this.followerGain);\n this.followerOsc2.connect(this.followerGain);\n this.followerOsc1.start();\n this.followerOsc2.start();\n\n this.enabled = true;\n } catch (e) {\n console.warn(\"Web Audio not supported or blocked\", e);\n }\n }\n\n toggle() {\n if (!this.ctx) {\n this.init();\n return true;\n }\n if (this.ctx.state === 'suspended') {\n this.ctx.resume();\n this.enabled = true;\n return true;\n }\n this.enabled = !this.enabled;\n if (!this.enabled && this.leaderGain && this.followerGain) {\n this.leaderGain.gain.setValueAtTime(0.0, this.ctx.currentTime);\n this.followerGain.gain.setValueAtTime(0.0, this.ctx.currentTime);\n }\n return this.enabled;\n }\n\n updateMotors(leaderRpm, followerRpm, speedLeader, speedFollower) {\n if (!this.enabled || !this.ctx || this.ctx.state !== 'running') return;\n\n const now = this.ctx.currentTime;\n // Pitch proportional to RPM and velocity\n const baseFreq1 = 85 + (speedLeader * 2.5) + ((leaderRpm - 4500) * 0.015);\n const baseFreq2 = 88 + (speedFollower * 2.5) + ((followerRpm - 4500) * 0.015);\n\n this.leaderOsc1.frequency.setTargetAtTime(Math.max(60, Math.min(220, baseFreq1)), now, 0.08);\n this.leaderOsc2.frequency.setTargetAtTime(Math.max(120, Math.min(440, baseFreq1 * 2)), now, 0.08);\n\n this.followerOsc1.frequency.setTargetAtTime(Math.max(60, Math.min(220, baseFreq2)), now, 0.08);\n this.followerOsc2.frequency.setTargetAtTime(Math.max(120, Math.min(440, baseFreq2 * 2)), now, 0.08);\n\n this.leaderGain.gain.setTargetAtTime(0.08, now, 0.1);\n this.followerGain.gain.setTargetAtTime(0.08, now, 0.1);\n }\n\n playBeep(freq = 880, duration = 0.08) {\n if (!this.enabled || !this.ctx) return;\n try {\n const osc = this.ctx.createOscillator();\n const gain = this.ctx.createGain();\n osc.type = \"sine\";\n osc.frequency.setValueAtTime(freq, this.ctx.currentTime);\n gain.gain.setValueAtTime(0.08, this.ctx.currentTime);\n gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);\n osc.connect(gain);\n gain.connect(this.masterGain);\n osc.start();\n osc.stop(this.ctx.currentTime + duration);\n } catch (e) {}\n }\n}\n\nwindow.droneAudio = new DroneAudioSystem();\n''')\n", "\n", "# static/js/drone_model.js ์ž‘์„ฑ\n", "with open(\"static/js/drone_model.js\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''/**\n * Procedural 3D Quadcopter Drone Builder in Three.js\n * Generates high-fidelity carbon-fiber military/tactical UAV models with animated rotors,\n * navigation LEDs, gimbal camera, landing skids, and thrust trail effects.\n */\n\nclass DroneModel {\n constructor(scene, options = {}) {\n this.scene = scene;\n this.id = options.id || \"drone\";\n this.name = options.name || \"UAV\";\n this.primaryColor = options.primaryColor || 0x00f0ff;\n this.accentColor = options.accentColor || 0x111625;\n this.isLeader = !!options.isLeader;\n\n this.group = new THREE.Group();\n this.rotors = [];\n this.rotorDiscs = [];\n this.strobeLeds = [];\n this.thrustParticles = [];\n\n this._buildModel();\n this.scene.add(this.group);\n }\n\n _buildModel() {\n // High quality PBR materials\n const carbonMat = new THREE.MeshStandardMaterial({\n color: 0x1a1e24,\n metalness: 0.85,\n roughness: 0.3,\n });\n\n const metalMat = new THREE.MeshStandardMaterial({\n color: 0x8892a0,\n metalness: 0.95,\n roughness: 0.15,\n });\n\n const canopyMat = new THREE.MeshStandardMaterial({\n color: 0x0d131a,\n metalness: 0.6,\n roughness: 0.1,\n clearcoat: 0.8,\n clearcoatRoughness: 0.1\n });\n\n const accentMat = new THREE.MeshStandardMaterial({\n color: this.primaryColor,\n metalness: 0.7,\n roughness: 0.3,\n emissive: this.primaryColor,\n emissiveIntensity: 0.35\n });\n\n const glowMat = new THREE.MeshBasicMaterial({\n color: this.primaryColor\n });\n\n // 1. Central Body Chassis (Lower & Upper aerodynamic hull)\n const bodyGeo = new THREE.CylinderGeometry(0.7, 0.9, 0.32, 8);\n bodyGeo.rotateY(Math.PI / 8);\n const bodyMesh = new THREE.Mesh(bodyGeo, carbonMat);\n bodyMesh.castShadow = true;\n bodyMesh.receiveShadow = true;\n this.group.add(bodyMesh);\n\n // Aerodynamic Top Canopy Shell\n const canopyGeo = new THREE.SphereGeometry(0.65, 16, 12);\n canopyGeo.scale(1.0, 0.45, 1.35);\n const canopyMesh = new THREE.Mesh(canopyGeo, canopyMat);\n canopyMesh.position.y = 0.18;\n canopyMesh.castShadow = true;\n this.group.add(canopyMesh);\n\n // Center Accent Stripe\n const stripeGeo = new THREE.BoxGeometry(0.12, 0.04, 1.4);\n const stripeMesh = new THREE.Mesh(stripeGeo, accentMat);\n stripeMesh.position.y = 0.34;\n this.group.add(stripeMesh);\n\n // 2. Gimbal Camera Sensor at Front Underbelly\n const gimbalGroup = new THREE.Group();\n gimbalGroup.position.set(0, -0.15, 0.7);\n\n const gimbalBase = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.08, 12), metalMat);\n gimbalGroup.add(gimbalBase);\n\n const cameraSphere = new THREE.Mesh(new THREE.SphereGeometry(0.16, 16, 16), carbonMat);\n cameraSphere.position.y = -0.1;\n gimbalGroup.add(cameraSphere);\n\n // Camera Optical Lens (Glowing ring)\n const lensGeo = new THREE.CylinderGeometry(0.09, 0.09, 0.06, 16);\n lensGeo.rotateX(Math.PI / 2);\n const lensMesh = new THREE.Mesh(lensGeo, new THREE.MeshBasicMaterial({ color: 0x00ffff }));\n lensMesh.position.set(0, -0.1, 0.14);\n gimbalGroup.add(lensMesh);\n\n this.group.add(gimbalGroup);\n\n // 3. Carbon Fiber Rotor Arms (X-Configuration)\n const armLength = 1.9;\n const armRadius = 0.055;\n const armPositions = [\n { angle: Math.PI * 0.25, dir: 1, ledColor: 0x00ff66 }, // Front-Right (Green nav)\n { angle: Math.PI * 0.75, dir: -1, ledColor: 0xff0044 }, // Rear-Right (Red nav)\n { angle: Math.PI * 1.25, dir: 1, ledColor: 0xff0044 }, // Rear-Left (Red nav)\n { angle: Math.PI * 1.75, dir: -1, ledColor: 0x00ff66 }, // Front-Left (Green nav)\n ];\n\n this.rotors = [];\n this.rotorDiscs = [];\n\n armPositions.forEach((arm, i) => {\n const armGroup = new THREE.Group();\n armGroup.rotation.y = arm.angle;\n\n // Arm Carbon Tube\n const armGeo = new THREE.CylinderGeometry(armRadius, armRadius, armLength, 12);\n armGeo.rotateZ(Math.PI / 2);\n const armMesh = new THREE.Mesh(armGeo, carbonMat);\n armMesh.position.x = armLength * 0.5;\n armMesh.castShadow = true;\n armGroup.add(armMesh);\n\n // Diagonal support truss\n const trussGeo = new THREE.CylinderGeometry(0.025, 0.025, armLength * 0.6, 8);\n trussGeo.rotateZ(Math.PI / 2.8);\n const trussMesh = new THREE.Mesh(trussGeo, carbonMat);\n trussMesh.position.set(armLength * 0.35, -0.1, 0);\n armGroup.add(trussMesh);\n\n // Brushless Electric Motor Housing at Arm Tip\n const motorGroup = new THREE.Group();\n motorGroup.position.x = armLength;\n\n const motorBase = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.22, 16), metalMat);\n motorBase.position.y = 0.05;\n motorBase.castShadow = true;\n motorGroup.add(motorBase);\n\n // Motor Accent Ring\n const ringGeo = new THREE.TorusGeometry(0.185, 0.02, 8, 20);\n ringGeo.rotateX(Math.PI / 2);\n const ringMesh = new THREE.Mesh(ringGeo, accentMat);\n ringMesh.position.y = 0.08;\n motorGroup.add(ringMesh);\n\n // Tip LED Navigation Light\n const ledGeo = new THREE.SphereGeometry(0.06, 8, 8);\n const ledMat = new THREE.MeshBasicMaterial({ color: arm.ledColor });\n const ledMesh = new THREE.Mesh(ledGeo, ledMat);\n ledMesh.position.set(0.12, -0.05, 0);\n motorGroup.add(ledMesh);\n this.strobeLeds.push(ledMesh);\n\n // Rotating Rotor Assembly\n const propGroup = new THREE.Group();\n propGroup.position.y = 0.18;\n\n // Rotor Hub Cap\n const hub = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.1, 12), metalMat);\n hub.position.y = 0.04;\n propGroup.add(hub);\n\n // 3-Blade Propeller\n const bladeMat = new THREE.MeshStandardMaterial({\n color: 0x111111,\n roughness: 0.4,\n metalness: 0.5\n });\n\n for (let b = 0; b < 3; b++) {\n const bladeAngle = (b * Math.PI * 2) / 3;\n const bladeGeo = new THREE.BoxGeometry(0.85, 0.015, 0.12);\n const bladeMesh = new THREE.Mesh(bladeGeo, bladeMat);\n bladeMesh.position.set(Math.cos(bladeAngle) * 0.42, 0, Math.sin(bladeAngle) * 0.42);\n bladeMesh.rotation.y = -bladeAngle;\n bladeMesh.rotation.x = 0.18; // Pitch twist\n bladeMesh.castShadow = true;\n propGroup.add(bladeMesh);\n }\n\n // Motion blur rotor disc (semi-transparent glowing disc when spinning fast)\n const discGeo = new THREE.RingGeometry(0.1, 0.95, 24);\n discGeo.rotateX(Math.PI / 2);\n const discMat = new THREE.MeshBasicMaterial({\n color: this.primaryColor,\n transparent: true,\n opacity: 0.22,\n side: THREE.DoubleSide\n });\n const discMesh = new THREE.Mesh(discGeo, discMat);\n discMesh.position.y = 0.02;\n propGroup.add(discMesh);\n\n motorGroup.add(propGroup);\n armGroup.add(motorGroup);\n this.group.add(armGroup);\n\n this.rotors.push({ group: propGroup, dir: arm.dir });\n this.rotorDiscs.push(discMesh);\n });\n\n // 4. Dual Carbon Landing Skids\n for (const side of [-1, 1]) {\n const skidGroup = new THREE.Group();\n skidGroup.position.set(side * 0.55, -0.22, 0);\n\n // Struts\n const strut1 = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.4, 8), metalMat);\n strut1.position.set(0, -0.15, 0.4);\n strut1.rotation.z = side * 0.25;\n skidGroup.add(strut1);\n\n const strut2 = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.4, 8), metalMat);\n strut2.position.set(0, -0.15, -0.4);\n strut2.rotation.z = side * 0.25;\n skidGroup.add(strut2);\n\n // Longitudinal Runner\n const runnerGeo = new THREE.CylinderGeometry(0.04, 0.04, 1.4, 8);\n runnerGeo.rotateX(Math.PI / 2);\n const runner = new THREE.Mesh(runnerGeo, carbonMat);\n runner.position.set(side * 0.08, -0.35, 0);\n skidGroup.add(runner);\n\n this.group.add(skidGroup);\n }\n\n // 5. Underbelly Beacon Light\n const beaconLight = new THREE.PointLight(this.primaryColor, 1.8, 12);\n beaconLight.position.set(0, -0.25, 0);\n this.group.add(beaconLight);\n this.beaconLight = beaconLight;\n\n // 6. Altitude Guide Drop Line & Ground Target Disc\n this.altLineGeo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(0, 0, 0),\n new THREE.Vector3(0, -10, 0)\n ]);\n this.altLineMat = new THREE.LineDashedMaterial({\n color: this.primaryColor,\n dashSize: 0.4,\n gapSize: 0.2,\n opacity: 0.5,\n transparent: true\n });\n this.altLine = new THREE.Line(this.altLineGeo, this.altLineMat);\n this.altLine.computeLineDistances();\n this.scene.add(this.altLine);\n\n // Ground shadow/projection ring\n const groundRingGeo = new THREE.RingGeometry(0.3, 1.2, 24);\n groundRingGeo.rotateX(-Math.PI / 2);\n const groundRingMat = new THREE.MeshBasicMaterial({\n color: this.primaryColor,\n transparent: true,\n opacity: 0.35,\n side: THREE.DoubleSide\n });\n this.groundRing = new THREE.Mesh(groundRingGeo, groundRingMat);\n this.scene.add(this.groundRing);\n }\n\n update(state, dt) {\n if (!state) return;\n\n // Update Position & Attitude smoothly\n this.group.position.set(state.position.x, state.position.y, state.position.z);\n \n // Pitch, Yaw, Roll rotations (Order: YXZ)\n const pitchRad = THREE.MathUtils.degToRad(state.rotation.pitch);\n const rollRad = THREE.MathUtils.degToRad(state.rotation.roll);\n const yawRad = state.rotation.yaw !== undefined ? THREE.MathUtils.degToRad(state.rotation.yaw) : 0;\n\n this.group.rotation.set(0, 0, 0);\n this.group.rotation.order = \"YXZ\";\n this.group.rotation.y = yawRad;\n this.group.rotation.x = pitchRad;\n this.group.rotation.z = rollRad;\n\n // Spin Propellers at RPM rate\n const speed = state.speed || 0;\n const baseSpin = (dt * 55.0) + (speed * 0.05);\n this.rotors.forEach((rotor, idx) => {\n rotor.group.rotation.y += baseSpin * rotor.dir;\n });\n\n // Altitude Drop Line update\n const posY = this.group.position.y;\n const linePositions = new Float32Array([\n this.group.position.x, this.group.position.y, this.group.position.z,\n this.group.position.x, 0.02, this.group.position.z\n ]);\n this.altLine.geometry.setAttribute('position', new THREE.BufferAttribute(linePositions, 3));\n this.altLine.computeLineDistances();\n\n // Ground Shadow Ring update\n this.groundRing.position.set(this.group.position.x, 0.03, this.group.position.z);\n const ringScale = Math.max(0.6, 1.0 + (posY * 0.08));\n this.groundRing.scale.set(ringScale, ringScale, ringScale);\n\n // Flashing Strobe LEDs\n const strobe = Math.sin(Date.now() * 0.008) > 0.4 ? 1.0 : 0.2;\n if (this.beaconLight) {\n this.beaconLight.intensity = 1.0 + strobe * 1.5;\n }\n }\n\n destroy() {\n if (this.group && this.scene) this.scene.remove(this.group);\n if (this.altLine && this.scene) this.scene.remove(this.altLine);\n if (this.groundRing && this.scene) this.scene.remove(this.groundRing);\n }\n}\n\nwindow.DroneModel = DroneModel;\n''')\n", "\n", "# static/js/radar.js ์ž‘์„ฑ\n", "with open(\"static/js/radar.js\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''/**\n * Tactical Radar HUD Canvas Component\n * Real-time 2D top-down aerial map showing 5 drones, trail histories, and sweep beam\n */\nclass TacticalRadar {\n constructor(canvasId) {\n this.canvas = document.getElementById(canvasId);\n if (!this.canvas) return;\n this.ctx = this.canvas.getContext('2d');\n this.width = this.canvas.width = 220;\n this.height = this.canvas.height = 220;\n this.cx = this.width / 2;\n this.cy = this.height / 2;\n this.range = 75.0; // World meters represented on radar radius\n\n this.sweepAngle = 0.0;\n this.trails = {};\n this.maxTrail = 28;\n }\n\n render(drones) {\n if (!this.ctx || !drones || !drones.length) return;\n const ctx = this.ctx;\n const w = this.width;\n const h = this.height;\n const cx = this.cx;\n const cy = this.cy;\n const r = cx - 10;\n\n // Clear with dark HUD background\n ctx.fillStyle = \"rgba(8, 14, 22, 0.45)\";\n ctx.fillRect(0, 0, w, h);\n\n // Circular clip for radar scope\n ctx.save();\n ctx.beginPath();\n ctx.arc(cx, cy, r, 0, Math.PI * 2);\n ctx.clip();\n\n // Background subtle grid rings\n ctx.strokeStyle = \"rgba(0, 240, 255, 0.15)\";\n ctx.lineWidth = 1;\n for (let i = 1; i <= 3; i++) {\n ctx.beginPath();\n ctx.arc(cx, cy, (r / 3) * i, 0, Math.PI * 2);\n ctx.stroke();\n }\n\n // Crosshairs\n ctx.beginPath();\n ctx.moveTo(cx - r, cy);\n ctx.lineTo(cx + r, cy);\n ctx.moveTo(cx, cy - r);\n ctx.lineTo(cx, cy + r);\n ctx.stroke();\n\n // 45-degree guide dashes\n ctx.setLineDash([2, 4]);\n ctx.strokeStyle = \"rgba(0, 240, 255, 0.08)\";\n ctx.beginPath();\n ctx.moveTo(cx - r * 0.7, cy - r * 0.7);\n ctx.lineTo(cx + r * 0.7, cy + r * 0.7);\n ctx.moveTo(cx - r * 0.7, cy + r * 0.7);\n ctx.lineTo(cx + r * 0.7, cy - r * 0.7);\n ctx.stroke();\n ctx.setLineDash([]);\n\n // Radar Sweep Line & Beam Gradient\n this.sweepAngle += 0.035;\n if (this.sweepAngle > Math.PI * 2) this.sweepAngle -= Math.PI * 2;\n\n const sweepGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);\n sweepGrad.addColorStop(0, \"rgba(0, 240, 255, 0.0)\");\n sweepGrad.addColorStop(1, \"rgba(0, 240, 255, 0.18)\");\n\n ctx.fillStyle = sweepGrad;\n ctx.beginPath();\n ctx.moveTo(cx, cy);\n ctx.arc(cx, cy, r, this.sweepAngle - 0.45, this.sweepAngle);\n ctx.closePath();\n ctx.fill();\n\n ctx.strokeStyle = \"rgba(0, 240, 255, 0.8)\";\n ctx.lineWidth = 1.5;\n ctx.beginPath();\n ctx.moveTo(cx, cy);\n ctx.lineTo(cx + Math.cos(this.sweepAngle) * r, cy + Math.sin(this.sweepAngle) * r);\n ctx.stroke();\n\n // Convert world pos (X, Z) to radar canvas coords\n const scale = r / this.range;\n const toRadar = (pos) => ({\n x: cx + (pos.x * scale),\n y: cy + (pos.z * scale)\n });\n\n const leaderPos = toRadar(drones[0].position);\n\n // Draw formation link lines from leader to all wingmen\n ctx.strokeStyle = \"rgba(0, 240, 255, 0.25)\";\n ctx.lineWidth = 1;\n ctx.setLineDash([2, 2]);\n for (let i = 1; i < drones.length; i++) {\n const fPos = toRadar(drones[i].position);\n ctx.beginPath();\n ctx.moveTo(leaderPos.x, leaderPos.y);\n ctx.lineTo(fPos.x, fPos.y);\n ctx.stroke();\n }\n ctx.setLineDash([]);\n\n // Update & Render each drone\n drones.forEach((d, idx) => {\n const radPos = toRadar(d.position);\n if (!this.trails[d.id]) this.trails[d.id] = [];\n this.trails[d.id].push({ ...radPos });\n if (this.trails[d.id].length > this.maxTrail) this.trails[d.id].shift();\n\n // Draw Trail\n this._drawTrail(this.trails[d.id], d.color);\n\n // Draw Drone Blip\n const yawRad = THREE.MathUtils.degToRad(d.rotation.yaw || 0);\n const label = idx === 0 ? \"L-01\" : `W-0${idx + 1}`;\n this._drawDroneBlip(radPos.x, radPos.y, yawRad, d.color, label, idx === 0);\n });\n\n ctx.restore();\n\n // Outer Ring Border\n ctx.strokeStyle = \"rgba(0, 240, 255, 0.6)\";\n ctx.lineWidth = 1.5;\n ctx.beginPath();\n ctx.arc(cx, cy, r, 0, Math.PI * 2);\n ctx.stroke();\n\n // Compass Cardinal labels\n ctx.fillStyle = \"rgba(0, 240, 255, 0.75)\";\n ctx.font = \"9px 'Share Tech Mono', monospace\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(\"N\", cx, cy - r + 8);\n ctx.fillText(\"S\", cx, cy + r - 8);\n ctx.fillText(\"W\", cx - r + 8, cy);\n ctx.fillText(\"E\", cx + r - 8, cy);\n }\n\n _drawTrail(trail, hexColor) {\n if (trail.length < 2) return;\n const ctx = this.ctx;\n for (let i = 0; i < trail.length - 1; i++) {\n const alpha = (i / trail.length) * 0.45;\n ctx.strokeStyle = hexColor;\n ctx.globalAlpha = alpha;\n ctx.lineWidth = 1.5;\n ctx.beginPath();\n ctx.moveTo(trail[i].x, trail[i].y);\n ctx.lineTo(trail[i + 1].x, trail[i + 1].y);\n ctx.stroke();\n }\n ctx.globalAlpha = 1.0;\n }\n\n _drawDroneBlip(x, y, yaw, color, label, isLeader) {\n const ctx = this.ctx;\n ctx.save();\n ctx.translate(x, y);\n\n // Blip glow\n ctx.fillStyle = color;\n ctx.shadowColor = color;\n ctx.shadowBlur = isLeader ? 10 : 6;\n ctx.beginPath();\n ctx.arc(0, 0, isLeader ? 4.5 : 3.0, 0, Math.PI * 2);\n ctx.fill();\n\n // Heading arrow\n ctx.rotate(yaw);\n ctx.strokeStyle = color;\n ctx.lineWidth = isLeader ? 2 : 1.5;\n ctx.beginPath();\n ctx.moveTo(0, 0);\n ctx.lineTo(0, isLeader ? 10 : 7);\n ctx.stroke();\n\n ctx.restore();\n\n // Text label\n ctx.fillStyle = \"#ffffff\";\n ctx.font = \"8px 'Share Tech Mono', monospace\";\n ctx.textAlign = \"left\";\n ctx.fillText(label, x + 5, y - 3);\n }\n}\n\nwindow.TacticalRadar = TacticalRadar;\n''')\n", "\n", "# static/js/main.js ์ž‘์„ฑ\n", "with open(\"static/js/main.js\", \"w\", encoding=\"utf-8\") as f:\n", " f.write('''/**\n * 5-Drone Formation Flight Simulator - Main Application Controller\n * Three.js 3D Environment, Mouse Raycasting Flight Control, WebSocket Telemetry Sync\n */\n\nclass DroneSimulatorApp {\n constructor() {\n this.container = document.getElementById(\"canvas-container\");\n this.scene = null;\n this.camera = null;\n this.renderer = null;\n this.controls = null;\n this.clock = new THREE.Clock();\n\n this.droneModels = [];\n this.formationBeams = [];\n this.targetReticle = null;\n this.targetPillar = null;\n this.radar = null;\n\n // Mouse & Flight Target state\n this.raycaster = new THREE.Raycaster();\n this.mouse = new THREE.Vector2();\n this.flightPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -8.0);\n this.targetPos = new THREE.Vector3(0, 8.0, 0);\n this.targetAltitude = 8.0;\n this.isMouseDown = false;\n\n // Camera mode: 'ORBIT', 'CHASE', 'FPV', 'FORMATION'\n this.cameraMode = 'ORBIT';\n\n // WebSocket Client\n this.ws = null;\n this.wsConnected = false;\n this.lastTelemetry = null;\n\n this.initThree();\n this.initEnvironment();\n this.initDrones();\n this.initEventListeners();\n this.initWebSocket();\n this.initRadar();\n\n this.animate = this.animate.bind(this);\n requestAnimationFrame(this.animate);\n }\n\n initThree() {\n // Scene\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x060a10);\n this.scene.fog = new THREE.FogExp2(0x060a10, 0.010);\n\n // Camera\n this.camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.2, 500);\n this.camera.position.set(0, 26, 38);\n\n // WebGL Renderer with Soft Shadows\n this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: \"high-performance\" });\n this.renderer.setSize(window.innerWidth, window.innerHeight);\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.shadowMap.enabled = true;\n this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;\n this.renderer.toneMapping = THREE.ACESFilmicToneMapping;\n this.renderer.toneMappingExposure = 1.1;\n this.container.appendChild(this.renderer.domElement);\n\n // Orbit Controls for Tactical Free Cam\n this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);\n this.controls.enableDamping = true;\n this.controls.dampingFactor = 0.05;\n this.controls.maxPolarAngle = Math.PI / 2 - 0.02;\n this.controls.minDistance = 4;\n this.controls.maxDistance = 160;\n this.controls.target.set(0, 6, 0);\n\n // Ambient & Directional Lighting\n const ambientLight = new THREE.AmbientLight(0x223344, 1.2);\n this.scene.add(ambientLight);\n\n const sunLight = new THREE.DirectionalLight(0xddeeff, 2.0);\n sunLight.position.set(40, 80, 30);\n sunLight.castShadow = true;\n sunLight.shadow.mapSize.width = 2048;\n sunLight.shadow.mapSize.height = 2048;\n sunLight.shadow.camera.near = 10;\n sunLight.shadow.camera.far = 200;\n sunLight.shadow.camera.left = -70;\n sunLight.shadow.camera.right = 70;\n sunLight.shadow.camera.top = 70;\n sunLight.shadow.camera.bottom = -70;\n sunLight.shadow.bias = -0.0005;\n this.scene.add(sunLight);\n\n // Cyber Accent Rim Lights\n const cyanRim = new THREE.DirectionalLight(0x00f0ff, 1.0);\n cyanRim.position.set(-60, 30, -50);\n this.scene.add(cyanRim);\n\n const pinkRim = new THREE.DirectionalLight(0xff0066, 0.8);\n pinkRim.position.set(60, 25, 60);\n this.scene.add(pinkRim);\n }\n\n initEnvironment() {\n // Ground Grid\n const gridHelper = new THREE.GridHelper(200, 100, 0x00f0ff, 0x0f2233);\n gridHelper.position.y = 0.01;\n this.scene.add(gridHelper);\n\n const subGrid = new THREE.GridHelper(200, 20, 0x00f0ff, 0x00f0ff);\n subGrid.position.y = 0.02;\n subGrid.material.opacity = 0.3;\n subGrid.material.transparent = true;\n this.scene.add(subGrid);\n\n // Ground Plane Mesh\n const groundGeo = new THREE.PlaneGeometry(400, 400);\n groundGeo.rotateX(-Math.PI / 2);\n const groundMat = new THREE.MeshStandardMaterial({\n color: 0x060a10,\n roughness: 0.85,\n metalness: 0.2\n });\n const groundMesh = new THREE.Mesh(groundGeo, groundMat);\n groundMesh.receiveShadow = true;\n this.scene.add(groundMesh);\n\n // Concentric Landing Rings\n const padGroup = new THREE.Group();\n const padRing1 = new THREE.Mesh(\n new THREE.RingGeometry(14, 14.5, 64),\n new THREE.MeshBasicMaterial({ color: 0x00f0ff, side: THREE.DoubleSide })\n );\n padRing1.rotateX(-Math.PI / 2);\n padRing1.position.y = 0.03;\n padGroup.add(padRing1);\n\n const padRing2 = new THREE.Mesh(\n new THREE.RingGeometry(28, 28.6, 64),\n new THREE.MeshBasicMaterial({ color: 0x00f0ff, side: THREE.DoubleSide, opacity: 0.4, transparent: true })\n );\n padRing2.rotateX(-Math.PI / 2);\n padRing2.position.y = 0.03;\n padGroup.add(padRing2);\n this.scene.add(padGroup);\n\n // 3D Target Reticle Marker\n this.targetReticle = new THREE.Group();\n const reticleRing = new THREE.Mesh(\n new THREE.RingGeometry(0.9, 1.15, 32),\n new THREE.MeshBasicMaterial({ color: 0x00f0ff, side: THREE.DoubleSide, transparent: true, opacity: 0.85 })\n );\n reticleRing.rotateX(-Math.PI / 2);\n this.targetReticle.add(reticleRing);\n\n const crossGeo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(-1.6, 0, 0), new THREE.Vector3(1.6, 0, 0),\n new THREE.Vector3(0, 0, -1.6), new THREE.Vector3(0, 0, 1.6)\n ]);\n const crossMat = new THREE.LineBasicMaterial({ color: 0x00f0ff, opacity: 0.9, transparent: true });\n this.targetReticle.add(new THREE.LineSegments(crossGeo, crossMat));\n\n // Altitude Drop Pillar\n const pillarGeo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 8, 0)\n ]);\n this.targetPillar = new THREE.Line(pillarGeo, new THREE.LineDashedMaterial({\n color: 0x00f0ff,\n dashSize: 0.5,\n gapSize: 0.3,\n transparent: true,\n opacity: 0.6\n }));\n this.targetPillar.computeLineDistances();\n this.scene.add(this.targetPillar);\n this.scene.add(this.targetReticle);\n }\n\n initDrones() {\n // 5 Drone Configuration: Leader + 4 Wingmen\n const droneConfigs = [\n { id: \"alpha_1\", name: \"VIPER-01 (Leader)\", color: 0x00f0ff, isLeader: true },\n { id: \"alpha_2\", name: \"GHOST-02 (Wingman 1)\", color: 0xff0055, isLeader: false },\n { id: \"alpha_3\", name: \"PHANTOM-03 (Wingman 2)\", color: 0x00ff88, isLeader: false },\n { id: \"alpha_4\", name: \"SPECTER-04 (Wingman 3)\", color: 0xffb700, isLeader: false },\n { id: \"alpha_5\", name: \"SHADOW-05 (Wingman 4)\", color: 0xb026ff, isLeader: false },\n ];\n\n this.droneModels = droneConfigs.map(cfg => {\n return new DroneModel(this.scene, {\n id: cfg.id,\n name: cfg.name,\n primaryColor: cfg.color,\n isLeader: cfg.isLeader\n });\n });\n\n // 4 Formation Laser Beams connecting Leader to each Wingman\n this.formationBeams = [];\n for (let i = 1; i < 5; i++) {\n const beamGeo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(0, 8, 0),\n new THREE.Vector3(i * 3, 8, -i * 3)\n ]);\n const beamMat = new THREE.LineBasicMaterial({\n color: droneConfigs[i].color,\n transparent: true,\n opacity: 0.55,\n linewidth: 1.5\n });\n const beam = new THREE.Line(beamGeo, beamMat);\n this.scene.add(beam);\n this.formationBeams.push(beam);\n }\n }\n\n initRadar() {\n this.radar = new TacticalRadar(\"tactical-radar\");\n }\n\n initWebSocket() {\n const protocol = window.location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n const wsUrl = `${protocol}//${window.location.host}/ws`;\n\n const connect = () => {\n this.ws = new WebSocket(wsUrl);\n\n this.ws.onopen = () => {\n this.wsConnected = true;\n const statusDot = document.getElementById(\"connection-status-dot\");\n const statusText = document.getElementById(\"connection-status-text\");\n if (statusDot) statusDot.className = \"status-dot\";\n if (statusText) statusText.innerText = \"ONLINE (5-DRONES 60Hz)\";\n };\n\n this.ws.onmessage = (event) => {\n try {\n const data = JSON.parse(event.data);\n this.lastTelemetry = data;\n this.updateUI(data);\n } catch (e) {\n console.error(\"Telemetry error\", e);\n }\n };\n\n this.ws.onclose = () => {\n this.wsConnected = false;\n const statusDot = document.getElementById(\"connection-status-dot\");\n const statusText = document.getElementById(\"connection-status-text\");\n if (statusDot) statusDot.className = \"status-dot warning\";\n if (statusText) statusText.innerText = \"OFFLINE (RETRYING...)\";\n setTimeout(connect, 2000);\n };\n };\n\n connect();\n }\n\n sendWsCommand(payload) {\n if (this.ws && this.wsConnected && this.ws.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify(payload));\n }\n }\n\n initEventListeners() {\n window.addEventListener(\"resize\", () => {\n this.camera.aspect = window.innerWidth / window.innerHeight;\n this.camera.updateProjectionMatrix();\n this.renderer.setSize(window.innerWidth, window.innerHeight);\n });\n\n window.addEventListener(\"pointerdown\", (e) => {\n if (e.target.closest(\".side-panel, .top-nav, .bottom-bar\")) return;\n this.isMouseDown = true;\n this.updateMouseTarget(e);\n });\n\n window.addEventListener(\"pointermove\", (e) => {\n if (e.target.closest(\".side-panel, .top-nav, .bottom-bar\")) return;\n if (this.isMouseDown || e.buttons === 1) {\n this.updateMouseTarget(e);\n }\n });\n\n window.addEventListener(\"pointerup\", () => {\n this.isMouseDown = false;\n });\n\n // Altitude Adjustment with Mouse Wheel\n window.addEventListener(\"wheel\", (e) => {\n if (e.target.closest(\".side-panel\")) return;\n if (e.ctrlKey || e.altKey) return;\n\n const delta = e.deltaY > 0 ? -0.8 : 0.8;\n this.targetAltitude = Math.max(1.5, Math.min(35.0, this.targetAltitude + delta));\n this.flightPlane.constant = -this.targetAltitude;\n\n const altSlider = document.getElementById(\"altitude-slider\");\n const altVal = document.getElementById(\"alt-value\");\n if (altSlider) altSlider.value = this.targetAltitude;\n if (altVal) altVal.innerText = `${this.targetAltitude.toFixed(1)}m`;\n\n this.sendWsCommand({\n type: \"SET_TARGET\",\n x: this.targetPos.x,\n y: this.targetAltitude,\n z: this.targetPos.z\n });\n }, { passive: true });\n\n // Keyboard Controls\n window.addEventListener(\"keydown\", (e) => {\n if (e.key === \"w\" || e.key === \"W\" || e.key === \"ArrowUp\") {\n this.targetAltitude = Math.min(35.0, this.targetAltitude + 1.0);\n } else if (e.key === \"s\" || e.key === \"S\" || e.key === \"ArrowDown\") {\n this.targetAltitude = Math.max(1.5, this.targetAltitude - 1.0);\n } else if (e.key === \"1\") {\n this.setCameraMode(\"ORBIT\");\n } else if (e.key === \"2\") {\n this.setCameraMode(\"CHASE\");\n } else if (e.key === \"3\") {\n this.setCameraMode(\"FPV\");\n } else if (e.key === \"4\") {\n this.setCameraMode(\"FORMATION\");\n } else if (e.key === \"m\" || e.key === \"M\") {\n this.toggleAudio();\n } else if (e.key === \" \") {\n this.targetPos.set(0, this.targetAltitude, 0);\n }\n\n this.flightPlane.constant = -this.targetAltitude;\n this.sendWsCommand({\n type: \"SET_TARGET\",\n x: this.targetPos.x,\n y: this.targetAltitude,\n z: this.targetPos.z\n });\n });\n\n // Formation Mode Buttons\n document.querySelectorAll(\".btn-formation\").forEach(btn => {\n btn.addEventListener(\"click\", () => {\n document.querySelectorAll(\".btn-formation\").forEach(b => b.classList.remove(\"active\"));\n btn.classList.add(\"active\");\n const mode = btn.dataset.mode;\n this.sendWsCommand({ type: \"SET_FORMATION\", mode: mode });\n if (window.droneAudio) window.droneAudio.playBeep(920, 0.06);\n });\n });\n\n // Sliders\n const distSlider = document.getElementById(\"formation-dist-slider\");\n if (distSlider) {\n distSlider.addEventListener(\"input\", (e) => {\n const dist = parseFloat(e.target.value);\n document.getElementById(\"dist-value\").innerText = `${dist.toFixed(1)}m`;\n this.sendWsCommand({ type: \"SET_FORMATION\", distance: dist });\n });\n }\n\n const heightSlider = document.getElementById(\"formation-height-slider\");\n if (heightSlider) {\n heightSlider.addEventListener(\"input\", (e) => {\n const hOff = parseFloat(e.target.value);\n document.getElementById(\"height-value\").innerText = `${hOff > 0 ? '+' : ''}${hOff.toFixed(1)}m`;\n this.sendWsCommand({ type: \"SET_FORMATION\", height_offset: hOff });\n });\n }\n\n const speedSlider = document.getElementById(\"speed-slider\");\n if (speedSlider) {\n speedSlider.addEventListener(\"input\", (e) => {\n const spd = parseFloat(e.target.value);\n document.getElementById(\"speed-value\").innerText = `${spd.toFixed(1)}x`;\n this.sendWsCommand({ type: \"SET_SPEED\", speed: spd });\n });\n }\n\n const altSlider = document.getElementById(\"altitude-slider\");\n if (altSlider) {\n altSlider.addEventListener(\"input\", (e) => {\n this.targetAltitude = parseFloat(e.target.value);\n document.getElementById(\"alt-value\").innerText = `${this.targetAltitude.toFixed(1)}m`;\n this.flightPlane.constant = -this.targetAltitude;\n this.sendWsCommand({\n type: \"SET_TARGET\",\n x: this.targetPos.x,\n y: this.targetAltitude,\n z: this.targetPos.z\n });\n });\n }\n\n const autoToggle = document.getElementById(\"auto-flight-toggle\");\n const autoSelect = document.getElementById(\"auto-pattern-select\");\n if (autoToggle) {\n autoToggle.addEventListener(\"change\", (e) => {\n const enabled = e.target.checked;\n const pattern = autoSelect ? autoSelect.value : \"FIGURE_EIGHT\";\n this.sendWsCommand({ type: \"SET_AUTONOMOUS\", enabled: enabled, pattern: pattern });\n if (window.droneAudio) window.droneAudio.playBeep(enabled ? 1200 : 600, 0.1);\n });\n }\n if (autoSelect) {\n autoSelect.addEventListener(\"change\", (e) => {\n const pattern = e.target.value;\n const enabled = autoToggle ? autoToggle.checked : false;\n this.sendWsCommand({ type: \"SET_AUTONOMOUS\", enabled: enabled, pattern: pattern });\n });\n }\n\n document.querySelectorAll(\".btn-cam\").forEach(btn => {\n btn.addEventListener(\"click\", () => {\n this.setCameraMode(btn.dataset.cam);\n });\n });\n\n const resetBtn = document.getElementById(\"btn-reset\");\n if (resetBtn) {\n resetBtn.addEventListener(\"click\", () => {\n this.targetPos.set(0, 8.0, 0);\n this.targetAltitude = 8.0;\n this.flightPlane.constant = -8.0;\n this.sendWsCommand({ type: \"RESET_POSITION\" });\n if (window.droneAudio) window.droneAudio.playBeep(440, 0.15);\n });\n }\n\n const audioBtn = document.getElementById(\"btn-audio-toggle\");\n if (audioBtn) {\n audioBtn.addEventListener(\"click\", () => {\n this.toggleAudio();\n });\n }\n }\n\n toggleAudio() {\n if (!window.droneAudio) return;\n const active = window.droneAudio.toggle();\n const btn = document.getElementById(\"btn-audio-toggle\");\n if (btn) {\n btn.innerText = active ? \"๐Ÿ”Š SFX ON\" : \"๐Ÿ”‡ SFX OFF\";\n btn.style.borderColor = active ? \"var(--color-cyan)\" : \"rgba(255,255,255,0.2)\";\n }\n }\n\n setCameraMode(mode) {\n this.cameraMode = mode;\n document.querySelectorAll(\".btn-cam\").forEach(b => {\n b.classList.toggle(\"active\", b.dataset.cam === mode);\n });\n\n if (mode === 'ORBIT') {\n this.controls.enabled = true;\n } else {\n this.controls.enabled = false;\n }\n if (window.droneAudio) window.droneAudio.playBeep(1050, 0.05);\n }\n\n updateMouseTarget(e) {\n this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;\n this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;\n\n this.raycaster.setFromCamera(this.mouse, this.camera);\n const intersectPoint = new THREE.Vector3();\n\n if (this.raycaster.ray.intersectPlane(this.flightPlane, intersectPoint)) {\n this.targetPos.x = intersectPoint.x;\n this.targetPos.y = this.targetAltitude;\n this.targetPos.z = intersectPoint.z;\n\n this.sendWsCommand({\n type: \"SET_TARGET\",\n x: this.targetPos.x,\n y: this.targetAltitude,\n z: this.targetPos.z\n });\n }\n }\n\n updateUI(telemetry) {\n if (!telemetry || !telemetry.drones) return;\n const drones = telemetry.drones;\n const formation = telemetry.formation;\n\n // Render each drone in the telemetry list dynamically\n drones.forEach((d, idx) => {\n const posEl = document.getElementById(`tele-d${idx}-pos`);\n const spdEl = document.getElementById(`tele-d${idx}-spd`);\n const altEl = document.getElementById(`tele-d${idx}-alt`);\n const batEl = document.getElementById(`tele-d${idx}-bat`);\n const barEl = document.getElementById(`bar-d${idx}-bat`);\n\n if (posEl) posEl.innerText = `${d.position.x}, ${d.position.y}, ${d.position.z}`;\n if (spdEl) spdEl.innerText = `${d.speed} km/h`;\n if (altEl) altEl.innerText = `${d.position.y.toFixed(1)}m`;\n if (batEl) batEl.innerText = `${d.battery}%`;\n if (barEl) barEl.style.width = `${d.battery}%`;\n });\n\n // Formation Card\n const gaugeDist = document.getElementById(\"gauge-dist-val\");\n if (gaugeDist) gaugeDist.innerText = `${formation.distance.toFixed(1)}m`;\n\n const statMsg = document.getElementById(\"formation-status-msg\");\n if (statMsg) statMsg.innerText = `5-DRONE SWARM: ${formation.mode}`;\n\n // Audio update (leader + wingman 1)\n if (window.droneAudio && drones.length >= 2) {\n window.droneAudio.updateMotors(drones[0].rotors[0], drones[1].rotors[0], drones[0].speed, drones[1].speed);\n }\n }\n\n updateCamera(dt) {\n if (!this.lastTelemetry || !this.lastTelemetry.drones) return;\n const leader = this.lastTelemetry.drones[0];\n const leaderPos = leader.position;\n const leaderYaw = THREE.MathUtils.degToRad(leader.rotation.yaw || 0);\n\n const lVec = new THREE.Vector3(leaderPos.x, leaderPos.y, leaderPos.z);\n\n if (this.cameraMode === 'CHASE') {\n const offset = new THREE.Vector3(-Math.sin(leaderYaw) * 18, 8.0, -Math.cos(leaderYaw) * 18);\n const targetCamPos = lVec.clone().add(offset);\n this.camera.position.lerp(targetCamPos, dt * 4.0);\n this.camera.lookAt(lVec.clone().add(new THREE.Vector3(0, 1.0, 0)));\n\n } else if (this.cameraMode === 'FPV') {\n this.camera.position.copy(lVec).add(new THREE.Vector3(0, -0.2, 0.8));\n const forward = new THREE.Vector3(Math.sin(leaderYaw), 0, Math.cos(leaderYaw));\n this.camera.lookAt(this.camera.position.clone().add(forward.multiplyScalar(20)));\n\n } else if (this.cameraMode === 'FORMATION') {\n // High angle side profile showing all 5 drones\n const sideOffset = new THREE.Vector3(26, 12, 0);\n this.camera.position.lerp(lVec.clone().add(sideOffset), dt * 3.0);\n this.camera.lookAt(lVec);\n\n } else {\n this.controls.update();\n }\n }\n\n animate() {\n requestAnimationFrame(this.animate);\n const dt = Math.min(0.05, this.clock.getDelta());\n\n // Update 3D Target Reticle & Pillar\n if (this.targetReticle) {\n this.targetReticle.position.set(this.targetPos.x, 0.04, this.targetPos.z);\n this.targetReticle.rotation.y += 0.02;\n\n const pPos = new Float32Array([\n this.targetPos.x, 0, this.targetPos.z,\n this.targetPos.x, this.targetAltitude, this.targetPos.z\n ]);\n this.targetPillar.geometry.setAttribute('position', new THREE.BufferAttribute(pPos, 3));\n this.targetPillar.computeLineDistances();\n }\n\n // Render 5 Drones from Telemetry Stream\n if (this.lastTelemetry && this.lastTelemetry.drones) {\n const drones = this.lastTelemetry.drones;\n drones.forEach((d, idx) => {\n if (this.droneModels[idx]) {\n this.droneModels[idx].update(d, dt);\n }\n });\n\n // Update 4 Formation Laser Beams from Leader to Wingmen\n const lp = drones[0].position;\n for (let i = 1; i < drones.length; i++) {\n if (this.formationBeams[i - 1]) {\n const fp = drones[i].position;\n const beamPoints = new Float32Array([\n lp.x, lp.y, lp.z,\n fp.x, fp.y, fp.z\n ]);\n this.formationBeams[i - 1].geometry.setAttribute('position', new THREE.BufferAttribute(beamPoints, 3));\n }\n }\n\n // Update 2D Tactical Radar HUD\n if (this.radar) {\n this.radar.render(drones);\n }\n }\n\n this.updateCamera(dt);\n this.renderer.render(this.scene, this.camera);\n }\n}\n\nwindow.addEventListener(\"DOMContentLoaded\", () => {\n window.simApp = new DroneSimulatorApp();\n});\n''')\n", "\n", "print(\"โœ… ๋“œ๋ก  ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์†Œ์Šค ํŒŒ์ผ ์ƒ์„ฑ ์™„๋ฃŒ!\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3๋‹จ๊ณ„: ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์„œ๋ฒ„ ์‹œ์ž‘ ๋ฐ ์›น ์ ‘์† ๋งํฌ ์—ด๊ธฐ ๐ŸŽฎ\n", "์•„๋ž˜ ์…€์„ ์‹คํ–‰ํ•˜๋ฉด Python ๋ฐฑ์—”๋“œ ์„œ๋ฒ„๊ฐ€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋™์ž‘ํ•˜๊ณ , ๋ธŒ๋ผ์šฐ์ €๋กœ ์ ‘์†ํ•  ์ˆ˜ ์žˆ๋Š” **์™ธ๋ถ€ ๋งํฌ(Cloudflare URL)** ๋ฐ **Colab ๋‚ด์žฅ ๋งํฌ**๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import subprocess\n", "import time\n", "import re\n", "import os\n", "from IPython.display import display, HTML\n", "\n", "# ๊ธฐ์กด ์‹คํ–‰ ์ค‘์ธ ํ”„๋กœ์„ธ์Šค ์ •๋ฆฌ\n", "!pkill -f uvicorn > /dev/null 2>&1\n", "!pkill -f cloudflared > /dev/null 2>&1\n", "time.sleep(1)\n", "\n", "# 1. Uvicorn ์„œ๋ฒ„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์‹œ์ž‘\n", "print(\"๐Ÿ›ฐ๏ธ Uvicorn ์„œ๋ฒ„ ์‹œ์ž‘ ์ค‘...\")\n", "server_proc = subprocess.Popen([\"uvicorn\", \"app:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"], \n", " stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n", "time.sleep(2)\n", "\n", "# 2. Cloudflared ํ„ฐ๋„ ์‹œ์ž‘ (๋ฌด๋ฃŒ, ์ธ์ฆ ๋ถˆํ•„์š”, ์›น์†Œ์ผ“ 100% ์ง€์›)\n", "print(\"๐ŸŒ Cloudflare ํ„ฐ๋„ ์—ฐ๊ฒฐ ์ค‘...\")\n", "tunnel_log = \"/tmp/cloudflared.log\"\n", "if os.path.exists(tunnel_log):\n", " os.remove(tunnel_log)\n", "\n", "tunnel_proc = subprocess.Popen([\"cloudflared\", \"tunnel\", \"--url\", \"http://127.0.0.1:8000\", \"--logfile\", tunnel_log],\n", " stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n", "\n", "# ํ„ฐ๋„ URL ์ถ”์ถœ\n", "public_url = None\n", "for _ in range(25):\n", " time.sleep(1)\n", " if os.path.exists(tunnel_log):\n", " with open(tunnel_log, \"r\") as f:\n", " content = f.read()\n", " match = re.search(r'https://[a-zA-Z0-9-]+\\.trycloudflare\\.com', content)\n", " if match:\n", " public_url = match.group(0)\n", " break\n", "\n", "# Colab ๋‚ด์žฅ ํฌํŠธ ๋งํฌ ์ƒ์„ฑ (๋Œ€์ฒด ์˜ต์…˜)\n", "try:\n", " from google.colab.output import eval_js\n", " colab_port_url = eval_js(\"google.colab.kernel.proxyPort(8000)\")\n", "except Exception:\n", " colab_port_url = None\n", "\n", "# ๊ฒฐ๊ณผ ํ™”๋ฉด ์ถœ๋ ฅ\n", "html_output = \"\"\"\n", "
\n", "

\n", " ๐Ÿš€ ๋“œ๋ก  5๋Œ€ ๊ตฐ์ง‘ ๋น„ํ–‰ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ์ค‘!\n", "

\n", "

\n", " ์•„๋ž˜ ๋ฒ„ํŠผ ๋˜๋Š” ๋งํฌ๋ฅผ ํด๋ฆญํ•˜์—ฌ 3D ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ํ™”๋ฉด์œผ๋กœ ์ ‘์†ํ•˜์„ธ์š”.\n", "

\n", "\"\"\"\n", "\n", "if public_url:\n", " html_output += f\"\"\"\n", "
\n", " \n", " ๐Ÿ›ธ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์—ด๊ธฐ (Cloudflare Tunnel: {public_url})\n", " \n", "
\n", " \"\"\"\n", "else:\n", " html_output += \"

โš ๏ธ Cloudflare ๋งํฌ ์ƒ์„ฑ ์ค‘์ž…๋‹ˆ๋‹ค. ๋ช‡ ์ดˆ ํ›„ ๋‹ค์‹œ ํ™•์ธํ•ด์ฃผ์„ธ์š”.

\"\n", "\n", "if colab_port_url:\n", " html_output += f\"\"\"\n", "
\n", " (Colab ํฌํŠธ ํ”„๋ก์‹œ ๋งํฌ: {colab_port_url})\n", "
\n", " \"\"\"\n", "\n", "html_output += \"\"\"\n", "
\n", " ๐ŸŽฎ ์กฐ์ข… ์•ˆ๋‚ด:
\n", " โ€ข ๋งˆ์šฐ์Šค ํด๋ฆญ/๋“œ๋ž˜๊ทธ: ๋ฆฌ๋” ๋“œ๋ก  ๋ชฉํ‘œ ์œ„์น˜ ์ด๋™ (์œ™๋งจ 4๋Œ€ ์ž๋™ ์ถ”์ข…)
\n", " โ€ข ๋งˆ์šฐ์Šค ํœ  / W, S ํ‚ค: ๋น„ํ–‰ ๊ณ ๋„ ์ƒ์Šน/ํ•˜๊ฐ•
\n", " โ€ข ์ˆซ์ž 1~4 ํ‚ค: ์นด๋ฉ”๋ผ ๋ทฐ ์ „ํ™˜ (1: ๊ถค๋„๋ทฐ, 2: ๋ฆฌ๋” ์ถ”์ , 3: 1์ธ์นญ FPV, 4: ํŽธ๋Œ€ ๋ทฐ)
\n", " โ€ข M ํ‚ค: ์‚ฌ์šด๋“œ On/Off | Space ํ‚ค: ์œ„์น˜ ์ดˆ๊ธฐํ™”\n", "
\n", "
\n", "\"\"\"\n", "\n", "display(HTML(html_output))\n", "if public_url:\n", " print(f\"\\n๐Ÿ‘‰ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์ ‘์† URL: {public_url}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## ๐Ÿ’ก (์„ ํƒ์‚ฌํ•ญ) Ngrok์„ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์€ ๊ฒฝ์šฐ\n", "Cloudflared ์™ธ์— Ngrok ๊ณ„์ •์ด ์žˆ์œผ์‹  ๊ฒฝ์šฐ ์•„๋ž˜ ์…€์„ ํ†ตํ•ด ์‹คํ–‰ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Ngrok Authtoken์ด ์žˆ๋Š” ๊ฒฝ์šฐ ์•„๋ž˜์— ์ž…๋ ฅ ํ›„ ์‹คํ–‰ (์„ ํƒ์‚ฌํ•ญ)\n", "NGROK_AUTH_TOKEN = \"\" # @param {type:\"string\"}\n", "\n", "if NGROK_AUTH_TOKEN.strip():\n", " from pyngrok import ngrok, conf\n", " conf.get_default().auth_token = NGROK_AUTH_TOKEN.strip()\n", " ngrok_tunnel = ngrok.connect(8000, \"http\")\n", " print(f\"๐Ÿ›ธ Ngrok ํ„ฐ๋„ ์ƒ์„ฑ ์™„๋ฃŒ: {ngrok_tunnel.public_url}\")\n", "else:\n", " print(\"โ„น๏ธ NGROK_AUTH_TOKEN์„ ์ž…๋ ฅํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. 3๋‹จ๊ณ„์˜ Cloudflare ํ„ฐ๋„์„ ์ด์šฉํ•˜์„ธ์š”.\")" ] } ], "metadata": { "accelerator": "GPU", "colab": { "provenance": [] }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 0 }