From fa39d1b27e7529b80adabaea6b08d2bcab88fcaa Mon Sep 17 00:00:00 2001 From: Evan Scamehorn Date: Tue, 2 Dec 2025 13:10:07 -0600 Subject: [PATCH] drag and drop route markers --- src/InputManager.js | 111 ++++++++++++++------- src/RouteManager.js | 236 ++++++++++++++++++++++++-------------------- src/main.js | 35 +++---- 3 files changed, 226 insertions(+), 156 deletions(-) diff --git a/src/InputManager.js b/src/InputManager.js index 39294b9..6f5329e 100644 --- a/src/InputManager.js +++ b/src/InputManager.js @@ -1,71 +1,114 @@ import * as THREE from 'three'; export class InputManager { - constructor(camera, domElement, scene) { + constructor(camera, domElement, scene, controls) { this.camera = camera; this.domElement = domElement; this.scene = scene; + this.controls = controls; // Need access to controls to disable them during drag this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); - // State for Pan detection + // Interaction State this.downPosition = new THREE.Vector2(); - this.upPosition = new THREE.Vector2(); - this.isDragging = false; + this.dragObject = null; // The object currently being dragged (marker) + this.isPanning = false; // Callbacks - this.onClick = null; // Function(point, intersectionObject) + this.onClick = null; // (point, object) -> void + this.onDrag = null; // (object, newPoint) -> void + this.onDragEnd = null; // () -> void } init() { this.domElement.addEventListener('pointerdown', this.onPointerDown.bind(this)); + this.domElement.addEventListener('pointermove', this.onPointerMove.bind(this)); this.domElement.addEventListener('pointerup', this.onPointerUp.bind(this)); } onPointerDown(event) { - if (event.button !== 0) return; // Only left click + if (event.button !== 0) return; // Left click only + // Record start position for Pan detection this.downPosition.set(event.clientX, event.clientY); - this.isDragging = false; + this.isPanning = false; + + // Raycast to see what we hit (Marker vs Ground) + const hit = this.raycast(event); + + if (hit) { + // Case A: We hit a Marker -> Start Dragging + if (hit.object.userData.isMarker) { + this.dragObject = hit.object; + this.controls.enabled = false; // Disable camera orbit + this.domElement.style.cursor = 'grabbing'; + } + } + } + + onPointerMove(event) { + // Case A: Dragging a Marker + if (this.dragObject) { + // Raycast against the GROUND to find where we are dragging to + const hit = this.raycastGround(event); + if (hit && this.onDrag) { + this.onDrag(this.dragObject, hit.point); + } + return; + } + + // Case B: Detecting Pan + // If mouse is down and moving, check distance + // (We don't need continuous logic here, just the final check in pointerUp is usually enough, + // but for "floating pointer" later we'd use this.) } onPointerUp(event) { if (event.button !== 0) return; - this.upPosition.set(event.clientX, event.clientY); - - // Calculate distance moved - const distance = this.downPosition.distanceTo(this.upPosition); - - // Threshold (pixels): If moved more than 3px, it's a pan, not a click - if (distance > 3) { - this.isDragging = true; - return; // Ignore + // 1. If we were dragging a marker, stop now. + if (this.dragObject) { + this.dragObject = null; + this.controls.enabled = true; // Re-enable camera + this.domElement.style.cursor = 'auto'; + if (this.onDragEnd) this.onDragEnd(); + return; // Don't trigger a click } - this.handleClick(event); + // 2. Check if it was a Camera Pan (move > 3px) + const upPosition = new THREE.Vector2(event.clientX, event.clientY); + if (this.downPosition.distanceTo(upPosition) > 3) { + return; // It was a pan, ignore + } + + // 3. It was a clean Click (Place new node) + const hit = this.raycast(event); + if (hit && hit.object.name === "GROUND" && this.onClick) { + this.onClick(hit.point, hit.object); + } } - handleClick(event) { - // 1. Normalize Mouse - this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1; - this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; + // --- Helpers --- - // 2. Raycast - this.raycaster.setFromCamera(this.mouse, this.camera); + getMouse(event) { + const r = this.domElement.getBoundingClientRect(); + const x = ((event.clientX - r.left) / r.width) * 2 - 1; + const y = -((event.clientY - r.top) / r.height) * 2 + 1; + return new THREE.Vector2(x, y); + } + + raycast(event) { + this.raycaster.setFromCamera(this.getMouse(event), this.camera); + // Intersection order: Markers (sorted by dist) -> Ground const intersects = this.raycaster.intersectObjects(this.scene.children, true); + // Return first valid hit (Marker or Ground) + return intersects.find(obj => obj.object.name === "GROUND" || obj.object.userData.isMarker); + } - if (intersects.length > 0) { - // Find the first relevant hit (Ground or Markers) - // For now, we prioritize Markers, then Ground - const hit = intersects.find(obj => - obj.object.name === "GROUND" || obj.object.userData.isMarker - ); - - if (hit && this.onClick) { - this.onClick(hit.point, hit.object); - } - } + raycastGround(event) { + this.raycaster.setFromCamera(this.getMouse(event), this.camera); + const intersects = this.raycaster.intersectObjects(this.scene.children, true); + return intersects.find(obj => obj.object.name === "GROUND"); } } diff --git a/src/RouteManager.js b/src/RouteManager.js index 3e94e75..14e595e 100644 --- a/src/RouteManager.js +++ b/src/RouteManager.js @@ -11,46 +11,37 @@ export class RouteManager { this.currentRouteNodes = []; // Visuals - this.markers = []; + this.markers = []; // Array of Meshes. index matches currentRouteNodes this.pathMesh = null; - // Constants - this.ROAD_OFFSET = 3.0; // Meters to right + this.ROAD_OFFSET = 2.5; // Meters } initGraph(data) { this.graphData = data; - - // Prepare Adjacency List (mirrors previous logic) - // IMPORTANT: Fix coordinates here once, so logic uses correct Z this.graphData.adjacency = {}; - // 1. Flip Y to Z for Nodes + // 1. Flip Coordinates (Data is +Y North, 3D is -Z North) for (let key in this.graphData.nodes) { this.graphData.nodes[key].y = -this.graphData.nodes[key].y; } - // 2. Process Edges + // 2. Build Adjacency this.graphData.edges.forEach((edge, index) => { - // Flip geometry points - if (edge.points) { - edge.points.forEach(p => { p[1] = -p[1]; }); - } + // Flip edge geometry + if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; }); + // Forward if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = []; this.graphData.adjacency[edge.u].push({ - to: edge.v, - cost: edge.length || 1, - edgeIndex: index + to: edge.v, cost: edge.length || 1, edgeIndex: index }); + // Reverse (if not oneway) if (!edge.oneway) { if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = []; this.graphData.adjacency[edge.v].push({ - to: edge.u, - cost: edge.length || 1, - edgeIndex: index, - isReverse: true + to: edge.u, cost: edge.length || 1, edgeIndex: index, isReverse: true }); } }); @@ -60,15 +51,12 @@ export class RouteManager { // Interaction Methods // ============================ - /** - * Called when user clicks the map. Adds a node to the route. - */ addNodeByWorldPosition(vector3) { if (!this.graphData) return; - const nodeId = this.findNearestNode(vector3.x, vector3.z); + if (nodeId === null) return; - // Prevent adding same node twice in a row + // Don't add duplicate adjacent nodes if (this.currentRouteNodes.length > 0 && this.currentRouteNodes[this.currentRouteNodes.length - 1] === nodeId) { return; @@ -76,24 +64,129 @@ export class RouteManager { this.currentRouteNodes.push(nodeId); - // Visuals - this.addMarker(nodeId); + // Add new marker + this.addMarkerVisual(nodeId); + + // Update path this.updatePathVisuals(); } - resetRoute() { - this.currentRouteNodes = []; - // Clear Visuals - this.markers.forEach(m => this.scene.remove(m)); - this.markers = []; - if (this.pathMesh) { - this.scene.remove(this.pathMesh); - this.pathMesh = null; + /** + * Called while dragging a marker. + * Updates the node at markerIndex to the nearest graph node at worldPoint. + */ + dragNode(markerObject, worldPoint) { + if (!this.graphData) return; + + // 1. Identify which node index this marker represents + const index = this.markers.indexOf(markerObject); + if (index === -1) return; + + // 2. Find nearest node to new mouse position + const newNodeId = this.findNearestNode(worldPoint.x, worldPoint.z); + + // 3. Optimization: Only update if the node ID actually changed + if (this.currentRouteNodes[index] !== newNodeId) { + + this.currentRouteNodes[index] = newNodeId; + + // Update Marker Visual Position + const nodeData = this.graphData.nodes[newNodeId]; + markerObject.position.set(nodeData.x, 2, nodeData.y); + markerObject.userData.nodeId = newNodeId; // Keep sync + + // Recalculate Path + this.updatePathVisuals(); } } // ============================ - // Logic & Algorithms + // Visual Logic + // ============================ + + updatePathVisuals() { + // Need 2+ nodes to make a path + if (this.currentRouteNodes.length < 2) { + if (this.pathMesh) { + this.scene.remove(this.pathMesh); + this.pathMesh = null; + } + return; + } + + // 1. Calculate Geometry + let fullPathPoints = []; + + for (let i = 0; i < this.currentRouteNodes.length - 1; i++) { + const start = this.currentRouteNodes[i]; + const end = this.currentRouteNodes[i + 1]; + + // Run A* for this segment + const segmentEdges = this.computePathAStar(start, end); + + if (!segmentEdges) { + // No path found (disconnected graph?), just draw straight line or skip + continue; + } + + // Process Geometry + segmentEdges.forEach(step => { + const rawPoints = step.edgeData.points; + let segmentPoints = rawPoints.map(p => new THREE.Vector2(p[0], p[1])); + if (step.isReverse) segmentPoints.reverse(); + + // Offset + const offsetSegment = this.getOffsetPath(segmentPoints, this.ROAD_OFFSET); + offsetSegment.forEach(p => fullPathPoints.push(new THREE.Vector3(p.x, 0.5, p.y))); + }); + } + + // 2. Update/Create Mesh + if (this.pathMesh) { + this.scene.remove(this.pathMesh); + this.pathMesh.geometry.dispose(); + } + + if (fullPathPoints.length < 2) return; + + const curve = new THREE.CatmullRomCurve3(fullPathPoints); + // Low tension = smoother corners + const tubeGeom = new THREE.TubeGeometry(curve, fullPathPoints.length, 1.5, 6, false); + const tubeMat = new THREE.MeshBasicMaterial({ color: this.settings.colors.route }); + + this.pathMesh = new THREE.Mesh(tubeGeom, tubeMat); + this.scene.add(this.pathMesh); + } + + addMarkerVisual(nodeId) { + const node = this.graphData.nodes[nodeId]; + const geom = new THREE.SphereGeometry(4); + + // Color Logic: Start(Green) -> End(Red). Intermediate? Yellow. + let color = this.settings.colors.pathStart; + if (this.markers.length > 0) color = this.settings.colors.pathEnd; // Default to End color + + // If we are adding a new end, turn the PREVIOUS end into a waypoint (Yellow) + if (this.markers.length > 0) { + // Change the previous last marker to yellow (waypoint) + // Unless it was the start marker (index 0) + if (this.markers.length > 1) { + this.markers[this.markers.length - 1].material.color.setHex(0xFFFF00); + } + } + + const mat = new THREE.MeshBasicMaterial({ color: color }); + const mesh = new THREE.Mesh(geom, mat); + + mesh.position.set(node.x, 2, node.y); + mesh.userData = { isMarker: true, nodeId: nodeId }; + + this.scene.add(mesh); + this.markers.push(mesh); + } + + // ============================ + // Algorithms (A* & Math) // ============================ findNearestNode(x, z) { @@ -101,7 +194,7 @@ export class RouteManager { let minDist = Infinity; for (const [id, node] of Object.entries(this.graphData.nodes)) { const dx = node.x - x; - const dz = node.y - z; // Graph Y is World Z + const dz = node.y - z; const d2 = dx * dx + dz * dz; if (d2 < minDist) { minDist = d2; @@ -111,76 +204,9 @@ export class RouteManager { return closestId; } - updatePathVisuals() { - // We need at least 2 nodes to draw a path - if (this.currentRouteNodes.length < 2) return; - - // 1. Calculate Full Path (Segment by Segment) - let fullPathPoints = []; - - for (let i = 0; i < this.currentRouteNodes.length - 1; i++) { - const start = this.currentRouteNodes[i]; - const end = this.currentRouteNodes[i + 1]; - - const segmentEdges = this.computePathAStar(start, end); - - if (!segmentEdges) { - console.warn(`No path found between ${start} and ${end}`); - continue; - } - - // Process Geometry for this segment - segmentEdges.forEach(step => { - const rawPoints = step.edgeData.points; - let segmentPoints = rawPoints.map(p => new THREE.Vector2(p[0], p[1])); - if (step.isReverse) segmentPoints.reverse(); - - const offsetSegment = this.getOffsetPath(segmentPoints, this.ROAD_OFFSET); - - offsetSegment.forEach(p => { - fullPathPoints.push(new THREE.Vector3(p.x, 0.5, p.y)); - }); - }); - } - - // 2. Draw Tube - if (this.pathMesh) this.scene.remove(this.pathMesh); - if (fullPathPoints.length < 2) return; - - const curve = new THREE.CatmullRomCurve3(fullPathPoints); - const tubeGeom = new THREE.TubeGeometry(curve, fullPathPoints.length, 1.5, 6, false); - const tubeMat = new THREE.MeshBasicMaterial({ color: this.settings.colors.route }); - - this.pathMesh = new THREE.Mesh(tubeGeom, tubeMat); - this.scene.add(this.pathMesh); - } - - addMarker(nodeId) { - const node = this.graphData.nodes[nodeId]; - const geom = new THREE.SphereGeometry(4); - - // Color logic: Green for start, Red for end, Yellow for waypoints - let color = this.settings.colors.pathStart; - if (this.markers.length > 0) color = 0xFFFF00; // Middle - - const mat = new THREE.MeshBasicMaterial({ color: color }); - const mesh = new THREE.Mesh(geom, mat); - mesh.position.set(node.x, 2, node.y); - mesh.userData = { isMarker: true, nodeId: nodeId }; // Tag for input manager - - this.scene.add(mesh); - this.markers.push(mesh); - - // Update last marker to Red - if (this.markers.length > 1) { - this.markers[this.markers.length - 1].material.color.setHex(this.settings.colors.pathEnd); - } - } - - // ============================ - // A* Implementation - // ============================ computePathAStar(start, end) { + if (start === end) return []; + const openSet = new Set([start]); const cameFrom = {}; const gScore = {}; diff --git a/src/main.js b/src/main.js index c190a2b..ddd8673 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,7 @@ import * as THREE from 'three'; import { MapControls } from 'three/addons/controls/MapControls.js'; import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js'; -// Import our new Phase 1 Managers +// Import our Classes import { InputManager } from './InputManager.js'; import { RouteManager } from './RouteManager.js'; @@ -33,32 +33,36 @@ let inputManager, routeManager; function init() { setupScene(); - // -- PHASE 1 INITIALIZATION -- - // Initialize Managers - inputManager = new InputManager(camera, renderer.domElement, scene); + // -- INITIALIZATION -- + // 1. Create Route Manager routeManager = new RouteManager(scene, SETTINGS); - // Wire up Input to Route Logic + // 2. Create Input Manager (Pass controls so we can disable them during drag) + inputManager = new InputManager(camera, renderer.domElement, scene, controls); inputManager.init(); + + // 3. Wire Events + + // Handle Click (Add Node) inputManager.onClick = (point, object) => { - // If we clicked the ground, we add a node to the route if (object.name === "GROUND") { routeManager.addNodeByWorldPosition(point); } - // If we clicked a marker, we could eventually select it for dragging (Phase 3) - else if (object.userData.isMarker) { - console.log("Clicked Marker:", object.userData.nodeId); - } }; - // Load Data + // Handle Drag (Move Node) + inputManager.onDrag = (markerObject, newPoint) => { + routeManager.dragNode(markerObject, newPoint); + }; + + // 4. Load Data Promise.all([ fetch(SETTINGS.files.visual).then(r => r.json()), fetch(SETTINGS.files.routing).then(r => r.json()) ]).then(([visual, routing]) => { console.log("Data loaded."); renderCity(visual); - routeManager.initGraph(routing); // Pass data to RouteManager + routeManager.initGraph(routing); }); animate(); @@ -80,9 +84,9 @@ function setupScene() { renderer.shadowMap.enabled = true; document.body.appendChild(renderer.domElement); - // Lights const ambient = new THREE.HemisphereLight(0xffffff, 0x555555, 0.7); scene.add(ambient); + const dirLight = new THREE.DirectionalLight(0xffffff, 1.5); dirLight.position.set(500, 1000, 500); dirLight.castShadow = true; @@ -93,7 +97,6 @@ function setupScene() { dirLight.shadow.camera.bottom = -1500; scene.add(dirLight); - // Ground Plane const plane = new THREE.Mesh( new THREE.PlaneGeometry(10000, 10000), new THREE.MeshLambertMaterial({ color: SETTINGS.colors.ground }) @@ -104,7 +107,6 @@ function setupScene() { plane.receiveShadow = true; scene.add(plane); - // Controls controls = new MapControls(camera, renderer.domElement); controls.dampingFactor = 0.05; controls.enableDamping = true; @@ -118,10 +120,9 @@ function setupScene() { } // ========================================== -// 3. Visual Rendering (Static City) +// 3. Visual Rendering // ========================================== function renderCity(data) { - // This logic is unchanged from before, just strictly for static geometry const createLayer = (items, color, height, lift, isExtruded) => { if (!items || !items.length) return; const geometries = [];