图形拾取

学习Canvas图形拾取技术,掌握点击检测、图形选择和碰撞检测方法。图形拾取是指检测用户点击了哪个图形,是Canvas交互的核心技术。

基本原理

检测流程

  1. 获取鼠标/触摸坐标
  2. 遍历图形列表(从后往前)
  3. 判断坐标是否在图形内
  4. 返回第一个匹配的图形

为什么从后往前遍历

// 从后往前遍历,因为后绘制的图形在上层
for (let i = shapes.length - 1; i >= 0; i--) {
  if (shapes[i].containsPoint(x, y)) {
    return shapes[i]
  }
}

点与图形检测

点与矩形

function pointInRect(px, py, rx, ry, rw, rh) {
  return px >= rx && px <= rx + rw &&
         py >= ry && py <= ry + rh
}

class RectShape {
  constructor(x, y, width, height) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
  }
  
  containsPoint(px, py) {
    return pointInRect(px, py, this.x, this.y, this.width, this.height)
  }
}

点与圆形

function pointInCircle(px, py, cx, cy, radius) {
  const dx = px - cx
  const dy = py - cy
  return dx * dx + dy * dy <= radius * radius
}

class CircleShape {
  constructor(x, y, radius) {
    this.x = x
    this.y = y
    this.radius = radius
  }
  
  containsPoint(px, py) {
    return pointInCircle(px, py, this.x, this.y, this.radius)
  }
}

点与椭圆

function pointInEllipse(px, py, cx, cy, rx, ry) {
  const dx = px - cx
  const dy = py - cy
  return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
}

class EllipseShape {
  constructor(x, y, radiusX, radiusY) {
    this.x = x
    this.y = y
    this.radiusX = radiusX
    this.radiusY = radiusY
  }
  
  containsPoint(px, py) {
    return pointInEllipse(px, py, this.x, this.y, this.radiusX, this.radiusY)
  }
}

点与多边形

function pointInPolygon(px, py, vertices) {
  let inside = false
  
  for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
    const xi = vertices[i].x
    const yi = vertices[i].y
    const xj = vertices[j].x
    const yj = vertices[j].y
    
    const intersect = ((yi > py) !== (yj > py)) &&
                    (px < (xj - xi) * (py - yi) / (yj - yi) + xi)
    
    if (intersect) inside = !inside
  }
  
  return inside
}

class PolygonShape {
  constructor(vertices) {
    this.vertices = vertices
  }
  
  containsPoint(px, py) {
    return pointInPolygon(px, py, this.vertices)
  }
}

图形拾取演示

图形拾取(点击图形选中)

路径拾取

使用isPointInPath

class PathShape {
  constructor(path) {
    this.path = path
    this.path2D = new Path2D()
    this.path2D.addPath(path)
  }
  
  containsPoint(px, py) {
    return ctx.isPointInPath(px, py, this.path2D)
  }
}

const path = new Path2D()
path.moveTo(100, 100)
path.lineTo(200, 100)
path.lineTo(150, 150)
path.closePath()

const shape = new PathShape(path)

使用isPointInStroke

class StrokeShape {
  constructor(path, lineWidth) {
    this.path = path
    this.path2D = new Path2D()
    this.path2D.addPath(path)
    this.lineWidth = lineWidth
  }
  
  containsPoint(px, py) {
    return ctx.isPointInStroke(px, py, this.path2D, this.lineWidth)
  }
}

变换后的图形

考虑变换的拾取

class TransformedShape {
  constructor(x, y, width, height) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
    this.rotation = 0
    this.scale = 1
  }
  
  containsPoint(px, py) {
    const cx = this.x + this.width / 2
    const cy = this.y + this.height / 2
    
    const dx = px - cx
    const dy = py - cy
    
    const cos = Math.cos(-this.rotation)
    const sin = Math.sin(-this.rotation)
    
    const localX = (dx * cos - dy * sin) / this.scale + this.width / 2
    const localY = (dx * sin + dy * cos) / this.scale + this.height / 2
    
    return localX >= 0 && localX <= this.width &&
           localY >= 0 && localY <= this.height
  }
}

颜色拾取

使用getImageData

function pickColor(canvas, x, y) {
  const pixel = ctx.getImageData(x, y, 1, 1).data
  return {
    r: pixel[0],
    g: pixel[1],
    b: pixel[2],
    a: pixel[3]
  }
}

canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect()
  const x = Math.floor(e.clientX - rect.left)
  const y = Math.floor(e.clientY - rect.top)
  
  const color = pickColor(canvas, x, y)
  console.log('点击颜色:', color)
})

使用离屏Canvas

const idCanvas = document.createElement('canvas')
const idCtx = idCanvas.getContext('2d')
idCanvas.width = canvas.width
idCanvas.height = canvas.height

function drawWithIDs() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  idCtx.clearRect(0, 0, idCanvas.width, idCanvas.height)
  
  shapes.forEach((shape, index) => {
    const id = index + 1
    
    ctx.fillStyle = shape.color
    drawShape(ctx, shape)
    
    idCtx.fillStyle = `rgb(${id}, 0, 0)`
    drawShape(idCtx, shape)
  })
}

function pickShape(x, y) {
  const pixel = idCtx.getImageData(x, y, 1, 1).data
  const id = pixel[0]
  
  if (id > 0) {
    return shapes[id - 1]
  }
  return null
}

性能优化

空间分区

class SpatialGrid {
  constructor(cellSize, width, height) {
    this.cellSize = cellSize
    this.cols = Math.ceil(width / cellSize)
    this.rows = Math.ceil(height / cellSize)
    this.grid = new Map()
  }
  
  clear() {
    this.grid.clear()
  }
  
  getKey(x, y) {
    const col = Math.floor(x / this.cellSize)
    const row = Math.floor(y / this.cellSize)
    return `${col},${row}`
  }
  
  insert(shape) {
    const key = this.getKey(shape.x, shape.y)
    if (!this.grid.has(key)) {
      this.grid.set(key, [])
    }
    this.grid.get(key).push(shape)
  }
  
  query(x, y) {
    const key = this.getKey(x, y)
    return this.grid.get(key) || []
  }
}

const grid = new SpatialGrid(50, 400, 200)

function findShapeAt(x, y) {
  const candidates = grid.query(x, y)
  
  for (let i = candidates.length - 1; i >= 0; i--) {
    if (candidates[i].containsPoint(x, y)) {
      return candidates[i]
    }
  }
  return null
}

四叉树优化

class QuadTree {
  constructor(bounds, capacity = 4) {
    this.bounds = bounds
    this.capacity = capacity
    this.objects = []
    this.divided = false
    this.children = null
  }
  
  insert(obj) {
    if (!this.contains(obj)) return false
    
    if (this.objects.length < this.capacity) {
      this.objects.push(obj)
      return true
    }
    
    if (!this.divided) {
      this.subdivide()
    }
    
    return this.children.some(child => child.insert(obj))
  }
  
  query(range, found = []) {
    if (!this.intersects(range)) return found
    
    this.objects.forEach(obj => {
      if (range.contains(obj)) {
        found.push(obj)
      }
    })
    
    if (this.divided) {
      this.children.forEach(child => child.query(range, found))
    }
    
    return found
  }
  
  contains(obj) {
    return obj.x >= this.bounds.x &&
           obj.x < this.bounds.x + this.bounds.width &&
           obj.y >= this.bounds.y &&
           obj.y < this.bounds.y + this.bounds.height
  }
  
  intersects(range) {
    return !(range.x > this.bounds.x + this.bounds.width ||
             range.x + range.width < this.bounds.x ||
             range.y > this.bounds.y + this.bounds.height ||
             range.y + range.height < this.bounds.y)
  }
}

图形拾取系统

class PickSystem {
  constructor(canvas) {
    this.canvas = canvas
    this.shapes = []
    this.selectedShape = null
    this.hoveredShape = null
    
    this.bindEvents()
  }
  
  addShape(shape) {
    this.shapes.push(shape)
    return this
  }
  
  removeShape(shape) {
    const index = this.shapes.indexOf(shape)
    if (index !== -1) {
      this.shapes.splice(index, 1)
    }
    return this
  }
  
  bindEvents() {
    this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
    this.canvas.addEventListener('click', this.onClick.bind(this))
  }
  
  getMousePos(e) {
    const rect = this.canvas.getBoundingClientRect()
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    }
    }
  
  findShapeAt(x, y) {
    for (let i = this.shapes.length - 1; i >= 0; i--) {
      if (this.shapes[i].containsPoint(x, y)) {
        return this.shapes[i]
      }
    }
    return null
  }
  
  onMouseMove(e) {
    const pos = this.getMousePos(e)
    const shape = this.findShapeAt(pos.x, pos.y)
    
    if (shape !== this.hoveredShape) {
      if (this.hoveredShape && this.hoveredShape.onMouseLeave) {
        this.hoveredShape.onMouseLeave()
      }
      this.hoveredShape = shape
      if (shape && shape.onMouseEnter) {
        shape.onMouseEnter()
      }
    }
  }
  
  onClick(e) {
    const pos = this.getMousePos(e)
    const shape = this.findShapeAt(pos.x, pos.y)
    
    if (this.selectedShape && this.selectedShape.onDeselect) {
      this.selectedShape.onDeselect()
    }
    
    this.selectedShape = shape
    
    if (shape && shape.onSelect) {
      shape.onSelect()
    }
  }
}

const pickSystem = new PickSystem(canvas)

pickSystem
  .addShape(new RectShape(50, 50, 100, 80))
  .addShape(new CircleShape(200, 100, 40))
  .addShape(new PolygonShape([
    { x: 300, y: 50 },
    { x: 350, y: 100 },
    { x: 300, y: 150 },
    { x: 250, y: 100 }
  ]))