feat(measurements): smarter labels, 45° snap, and circle click-through
- Labels go through a collision-resolver before painting: greedy top-to-bottom placement pushes overlapping pills downward, and labels shifted significantly draw a dashed leader line back to their anchor in the measurement's color. Same two-pass order is used by the live overlay and the annotated exports. - Unselected dark label pills now have a colored border matching the measurement, so a label can be paired to its geometry without relying on selection state. - New "Snap 45°" toolbar checkbox: when on, line endpoints and angle arms snap their direction to multiples of 45° (relative to the fixed endpoint or angle vertex) during placement and during handle drag. Length is preserved. - Circles: only the center and edge handles drag. Body / rim / label clicks select-only, and when a placement tool is active they fall through entirely so the user can draw a new measurement on top of an existing circle.
This commit is contained in:
parent
9c47736799
commit
b28ffe267b
@ -46,6 +46,10 @@ type ToolMode = "none" | "line" | "rectangle" | "ellipse" | "circle" | "angle"
|
|||||||
const activeTool = ref<ToolMode>("none")
|
const activeTool = ref<ToolMode>("none")
|
||||||
const showGrid = ref(false)
|
const showGrid = ref(false)
|
||||||
const gridSpacingMm = ref(10)
|
const gridSpacingMm = ref(10)
|
||||||
|
// When on, line/angle placements and endpoint drags snap their direction to
|
||||||
|
// the nearest 45° (0/45/90/135…) relative to the fixed endpoint or angle
|
||||||
|
// vertex. Length is preserved; only direction is constrained.
|
||||||
|
const snapToAngle = ref(false)
|
||||||
|
|
||||||
// Measurement types live in `@/types/measurements` so the cache module and
|
// Measurement types live in `@/types/measurements` so the cache module and
|
||||||
// other consumers can share them. Geometry is in image space so it stays
|
// other consumers can share them. Geometry is in image space so it stays
|
||||||
@ -193,6 +197,21 @@ function drawOverlay() {
|
|||||||
const selected = measurements.value.find((m) => m.id === selectedId.value)
|
const selected = measurements.value.find((m) => m.id === selectedId.value)
|
||||||
if (selected) drawMeasurement(ctx, selected, true, rt)
|
if (selected) drawMeasurement(ctx, selected, true, rt)
|
||||||
|
|
||||||
|
// Labels go in a second pass: collision-resolve all positions across
|
||||||
|
// the full set, then paint. Resolving after geometries means labels
|
||||||
|
// never get hidden by lines drawn after them, and one shared resolver
|
||||||
|
// keeps unselected and selected labels from overlapping each other.
|
||||||
|
const labelPositions = resolveLabelPositions(ctx, measurements.value, rt)
|
||||||
|
for (const m of measurements.value) {
|
||||||
|
if (m.id === selectedId.value) continue
|
||||||
|
const pos = labelPositions.get(m.id)
|
||||||
|
if (pos) drawLabelAt(ctx, m, false, rt, pos)
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
const pos = labelPositions.get(selected.id)
|
||||||
|
if (pos) drawLabelAt(ctx, selected, true, rt, pos)
|
||||||
|
}
|
||||||
|
|
||||||
// Placement preview overlaying everything, in the active tool's color
|
// Placement preview overlaying everything, in the active tool's color
|
||||||
// slot (= next palette slot the new measurement will claim).
|
// slot (= next palette slot the new measurement will claim).
|
||||||
if (activeTool.value !== "none" && placementPoints.value.length > 0) {
|
if (activeTool.value !== "none" && placementPoints.value.length > 0) {
|
||||||
@ -316,6 +335,24 @@ function screenToImg(sx: number, sy: number): Point {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If `snapToAngle` is on, rotate `to` around `from` to the nearest 45°
|
||||||
|
// multiple while preserving |to - from|. Used by line + angle placement
|
||||||
|
// and endpoint dragging so the user can lay down clean orthogonal /
|
||||||
|
// diagonal references without aiming pixel-perfect with the cursor.
|
||||||
|
function maybeSnap45(from: Point, to: Point): Point {
|
||||||
|
if (!snapToAngle.value) return to
|
||||||
|
const dx = to.x - from.x
|
||||||
|
const dy = to.y - from.y
|
||||||
|
const r = Math.hypot(dx, dy)
|
||||||
|
if (r < 1e-6) return to
|
||||||
|
const STEP = Math.PI / 4
|
||||||
|
const snapped = Math.round(Math.atan2(dy, dx) / STEP) * STEP
|
||||||
|
return {
|
||||||
|
x: from.x + r * Math.cos(snapped),
|
||||||
|
y: from.y + r * Math.sin(snapped),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-measurement dimensions, all in millimetres.
|
// Per-measurement dimensions, all in millimetres.
|
||||||
function lineLengthMm(m: LineMeasurement): number {
|
function lineLengthMm(m: LineMeasurement): number {
|
||||||
const dx = m.b.x - m.a.x
|
const dx = m.b.x - m.a.x
|
||||||
@ -520,7 +557,6 @@ function drawMeasurement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1.0
|
ctx.globalAlpha = 1.0
|
||||||
drawLabel(ctx, m, baseColor, isSelected, rt)
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -779,16 +815,125 @@ function drawHandle(
|
|||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawLabel(
|
// Resolved label position for one measurement after collision avoidance.
|
||||||
|
// `pos` is the rect to draw at, `anchor` is the geometry-space anchor in
|
||||||
|
// canvas coords (used to draw a leader line back when the label is shifted),
|
||||||
|
// and `shifted` flags whether collision-resolution moved the label far
|
||||||
|
// enough to warrant a leader line.
|
||||||
|
interface LabelPos {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
textX: number
|
||||||
|
textY: number
|
||||||
|
fontPx: number
|
||||||
|
anchor: Point
|
||||||
|
shifted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// AABB overlap with optional gap padding so labels don't sit flush against
|
||||||
|
// each other.
|
||||||
|
function rectsOverlap(
|
||||||
|
a: { x: number; y: number; w: number; h: number },
|
||||||
|
b: { x: number; y: number; w: number; h: number },
|
||||||
|
gap: number,
|
||||||
|
): boolean {
|
||||||
|
return !(
|
||||||
|
a.x + a.w + gap <= b.x ||
|
||||||
|
b.x + b.w + gap <= a.x ||
|
||||||
|
a.y + a.h + gap <= b.y ||
|
||||||
|
b.y + b.h + gap <= a.y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greedy collision avoidance: process labels top-to-bottom by anchor Y,
|
||||||
|
// place each at its desired position, and if it overlaps any already-placed
|
||||||
|
// label, push it down past the offender. Predictable, fast for typical
|
||||||
|
// measurement counts, and keeps every label still horizontally aligned with
|
||||||
|
// its anchor (we only shift in Y). Labels that move significantly get a
|
||||||
|
// leader line back to their anchor in `drawLabelAt`.
|
||||||
|
function resolveLabelPositions(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
list: Measurement[],
|
||||||
|
rt: RenderCtx,
|
||||||
|
): Map<string, LabelPos> {
|
||||||
|
const gap = 4 * rt.strokeMul
|
||||||
|
const items = list.map((m) => {
|
||||||
|
const anchor = imgToCtx(labelAnchor(m), rt)
|
||||||
|
const rect = labelRect(ctx, m, rt)
|
||||||
|
return { id: m.id, anchor, rect, originalY: rect.y }
|
||||||
|
})
|
||||||
|
items.sort((a, b) => a.anchor.y - b.anchor.y)
|
||||||
|
|
||||||
|
const placed: typeof items = []
|
||||||
|
for (const it of items) {
|
||||||
|
let safety = 50
|
||||||
|
while (safety-- > 0) {
|
||||||
|
let collided = false
|
||||||
|
for (const p of placed) {
|
||||||
|
if (rectsOverlap(it.rect, p.rect, gap)) {
|
||||||
|
const shift = p.rect.y + p.rect.h + gap - it.rect.y
|
||||||
|
it.rect = {
|
||||||
|
...it.rect,
|
||||||
|
y: it.rect.y + shift,
|
||||||
|
textY: it.rect.textY + shift,
|
||||||
|
}
|
||||||
|
collided = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!collided) break
|
||||||
|
}
|
||||||
|
placed.push(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Map<string, LabelPos>()
|
||||||
|
const shiftThreshold = 4 * rt.strokeMul
|
||||||
|
for (const it of placed) {
|
||||||
|
out.set(it.id, {
|
||||||
|
x: it.rect.x,
|
||||||
|
y: it.rect.y,
|
||||||
|
w: it.rect.w,
|
||||||
|
h: it.rect.h,
|
||||||
|
textX: it.rect.textX,
|
||||||
|
textY: it.rect.textY,
|
||||||
|
fontPx: it.rect.fontPx,
|
||||||
|
anchor: it.anchor,
|
||||||
|
shifted: Math.abs(it.rect.y - it.originalY) > shiftThreshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLabelAt(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
m: Measurement,
|
m: Measurement,
|
||||||
baseColor: string,
|
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
rt: RenderCtx,
|
rt: RenderCtx,
|
||||||
|
pos: LabelPos,
|
||||||
) {
|
) {
|
||||||
const rect = labelRect(ctx, m, rt)
|
const baseColor = getDatumColor(m.colorIndex)
|
||||||
const decorate = rt.drawSelectionDecorations
|
const decorate = rt.drawSelectionDecorations
|
||||||
const labelAlpha = decorate ? (isSelected ? 1.0 : 0.5) : 1.0
|
const labelAlpha = decorate ? (isSelected ? 1.0 : 0.7) : 1.0
|
||||||
|
|
||||||
|
// Leader line back to the geometry anchor when collision resolution
|
||||||
|
// dragged the label away from its preferred position. Drawn first so
|
||||||
|
// the label box paints over the line where they meet.
|
||||||
|
if (pos.shifted) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.globalAlpha = labelAlpha * 0.6
|
||||||
|
ctx.strokeStyle = baseColor
|
||||||
|
ctx.lineWidth = 1 * rt.strokeMul
|
||||||
|
ctx.setLineDash([3 * rt.strokeMul, 2 * rt.strokeMul])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(pos.anchor.x, pos.anchor.y)
|
||||||
|
ctx.lineTo(pos.x + pos.w / 2, pos.y + pos.h / 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.globalAlpha = labelAlpha
|
ctx.globalAlpha = labelAlpha
|
||||||
// In export mode every label uses the measurement's own colour for the
|
// In export mode every label uses the measurement's own colour for the
|
||||||
@ -799,18 +944,28 @@ function drawLabel(
|
|||||||
? baseColor
|
? baseColor
|
||||||
: "rgba(0, 0, 0, 0.75)"
|
: "rgba(0, 0, 0, 0.75)"
|
||||||
: baseColor
|
: baseColor
|
||||||
roundRect(ctx, rect.x, rect.y, rect.w, rect.h, 4 * rt.strokeMul)
|
roundRect(ctx, pos.x, pos.y, pos.w, pos.h, 4 * rt.strokeMul)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
if (decorate && isSelected) {
|
// Colored border on the dark unselected pill ties a label to its
|
||||||
ctx.strokeStyle = "#ffffff"
|
// geometry without relying on selection state — without this, every
|
||||||
ctx.lineWidth = 1 * rt.strokeMul
|
// unselected label looked identical. Selected labels use a white border
|
||||||
|
// for the highlight ring; in export-mode pills are filled with baseColor
|
||||||
|
// and don't need a border.
|
||||||
|
if (decorate) {
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.strokeStyle = "#ffffff"
|
||||||
|
ctx.lineWidth = 1 * rt.strokeMul
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = baseColor
|
||||||
|
ctx.lineWidth = 1.5 * rt.strokeMul
|
||||||
|
}
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
ctx.font = `bold ${String(rect.fontPx)}px monospace`
|
ctx.font = `bold ${String(pos.fontPx)}px monospace`
|
||||||
ctx.fillStyle = "#ffffff"
|
ctx.fillStyle = "#ffffff"
|
||||||
ctx.textAlign = "center"
|
ctx.textAlign = "center"
|
||||||
ctx.textBaseline = "middle"
|
ctx.textBaseline = "middle"
|
||||||
ctx.fillText(measurementLabel(m), rect.textX, rect.textY)
|
ctx.fillText(measurementLabel(m), pos.textX, pos.textY)
|
||||||
ctx.textAlign = "start"
|
ctx.textAlign = "start"
|
||||||
ctx.textBaseline = "alphabetic"
|
ctx.textBaseline = "alphabetic"
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
@ -850,8 +1005,20 @@ function drawPlacementPreview(ctx: CanvasRenderingContext2D) {
|
|||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2
|
||||||
ctx.setLineDash([4, 3])
|
ctx.setLineDash([4, 3])
|
||||||
|
|
||||||
|
// Snap the cursor for tools whose direction is meaningful: line snaps
|
||||||
|
// relative to the first endpoint, angle relative to the vertex (pts[0]).
|
||||||
|
// Rect and ellipse don't snap — rect is axis-aligned by construction
|
||||||
|
// and the ellipse's "direction" is just an axis label, not orientation
|
||||||
|
// the user typically cares to lock to 45°.
|
||||||
|
let effectiveCursor = cursor
|
||||||
|
if (cursor && pts.length >= 1 && pts[0]) {
|
||||||
|
if (activeTool.value === "line" || activeTool.value === "angle") {
|
||||||
|
effectiveCursor = maybeSnap45(pts[0], cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sPts = pts.map(imgToScreen)
|
const sPts = pts.map(imgToScreen)
|
||||||
const sCursor = cursor ? imgToScreen(cursor) : null
|
const sCursor = effectiveCursor ? imgToScreen(effectiveCursor) : null
|
||||||
|
|
||||||
if (activeTool.value === "line" && sPts.length >= 1 && sPts[0] && sCursor) {
|
if (activeTool.value === "line" && sPts.length >= 1 && sPts[0] && sCursor) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
@ -1232,7 +1399,17 @@ function commitPlacement() {
|
|||||||
|
|
||||||
function handlePlacementClick(imgPt: Point) {
|
function handlePlacementClick(imgPt: Point) {
|
||||||
if (activeTool.value === "none") return
|
if (activeTool.value === "none") return
|
||||||
placementPoints.value.push(imgPt)
|
// Snap the click before storing so the preview, the committed
|
||||||
|
// measurement, and any subsequent point all see the same coordinate.
|
||||||
|
// Mirrors `drawPlacementPreview`'s snap rules.
|
||||||
|
const pts = placementPoints.value
|
||||||
|
let pt = imgPt
|
||||||
|
if (pts.length >= 1 && pts[0]) {
|
||||||
|
if (activeTool.value === "line" || activeTool.value === "angle") {
|
||||||
|
pt = maybeSnap45(pts[0], imgPt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placementPoints.value.push(pt)
|
||||||
const needed =
|
const needed =
|
||||||
activeTool.value === "line" ||
|
activeTool.value === "line" ||
|
||||||
activeTool.value === "rectangle" ||
|
activeTool.value === "rectangle" ||
|
||||||
@ -1369,8 +1546,14 @@ function applyDrag(
|
|||||||
}
|
}
|
||||||
if (mode === "handle" && handleKey) {
|
if (mode === "handle" && handleKey) {
|
||||||
if (original.type === "line") {
|
if (original.type === "line") {
|
||||||
if (handleKey === "a") return { ...original, a: { x: original.a.x + dx, y: original.a.y + dy } }
|
if (handleKey === "a") {
|
||||||
if (handleKey === "b") return { ...original, b: { x: original.b.x + dx, y: original.b.y + dy } }
|
const raw = { x: original.a.x + dx, y: original.a.y + dy }
|
||||||
|
return { ...original, a: maybeSnap45(original.b, raw) }
|
||||||
|
}
|
||||||
|
if (handleKey === "b") {
|
||||||
|
const raw = { x: original.b.x + dx, y: original.b.y + dy }
|
||||||
|
return { ...original, b: maybeSnap45(original.a, raw) }
|
||||||
|
}
|
||||||
} else if (original.type === "rectangle") {
|
} else if (original.type === "rectangle") {
|
||||||
// Constrain to an axis-aligned rectangle: the dragged corner
|
// Constrain to an axis-aligned rectangle: the dragged corner
|
||||||
// follows the cursor, the diagonally-opposite corner stays put,
|
// follows the cursor, the diagonally-opposite corner stays put,
|
||||||
@ -1456,10 +1639,12 @@ function applyDrag(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (handleKey === "armA") {
|
if (handleKey === "armA") {
|
||||||
return { ...original, armA: { x: original.armA.x + dx, y: original.armA.y + dy } }
|
const raw = { x: original.armA.x + dx, y: original.armA.y + dy }
|
||||||
|
return { ...original, armA: maybeSnap45(original.vertex, raw) }
|
||||||
}
|
}
|
||||||
if (handleKey === "armB") {
|
if (handleKey === "armB") {
|
||||||
return { ...original, armB: { x: original.armB.x + dx, y: original.armB.y + dy } }
|
const raw = { x: original.armB.x + dx, y: original.armB.y + dy }
|
||||||
|
return { ...original, armB: maybeSnap45(original.vertex, raw) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1487,9 +1672,22 @@ function pointerDown(
|
|||||||
const cursor = { x: screenX, y: screenY }
|
const cursor = { x: screenX, y: screenY }
|
||||||
const hit = hitTest(cursor)
|
const hit = hitTest(cursor)
|
||||||
if (hit) {
|
if (hit) {
|
||||||
selectedId.value = hit.measurementId
|
|
||||||
const target = measurements.value.find((m) => m.id === hit.measurementId)
|
const target = measurements.value.find((m) => m.id === hit.measurementId)
|
||||||
if (target) {
|
// Circles are exclusively manipulated via their two handles —
|
||||||
|
// center (which translates the whole circle while keeping its
|
||||||
|
// radius) and edge (resize). Body and label hits don't drag.
|
||||||
|
const isCircleBodyHit =
|
||||||
|
target?.type === "circle" && hit.kind !== "handle"
|
||||||
|
// When a placement tool is active and the hit is on a circle's
|
||||||
|
// body, fall through to placement entirely — don't select, don't
|
||||||
|
// suppress the click. This lets the user draw a new measurement
|
||||||
|
// on top of an existing circle. Handles still claim the press
|
||||||
|
// so the circle can be reshaped from within an active tool too.
|
||||||
|
if (isCircleBodyHit && activeTool.value !== "none") {
|
||||||
|
return "placement"
|
||||||
|
}
|
||||||
|
selectedId.value = hit.measurementId
|
||||||
|
if (target && !isCircleBodyHit) {
|
||||||
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
|
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
|
||||||
dragState = {
|
dragState = {
|
||||||
mode,
|
mode,
|
||||||
@ -1983,10 +2181,21 @@ function exportWithMeasurements(opts: {
|
|||||||
scalePxPerMmForBar = props.scalePxPerMm * viewScale.value
|
scalePxPerMmForBar = props.scalePxPerMm * viewScale.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw every measurement, no selection distinction.
|
// Draw every measurement, no selection distinction. Geometries first,
|
||||||
|
// then collision-resolved labels — same two-pass order the live
|
||||||
|
// overlay uses, so labels stay legible when shapes are dense.
|
||||||
for (const m of measurements.value) {
|
for (const m of measurements.value) {
|
||||||
drawMeasurement(outCtx, m, false, renderCtx)
|
drawMeasurement(outCtx, m, false, renderCtx)
|
||||||
}
|
}
|
||||||
|
const exportLabelPositions = resolveLabelPositions(
|
||||||
|
outCtx,
|
||||||
|
measurements.value,
|
||||||
|
renderCtx,
|
||||||
|
)
|
||||||
|
for (const m of measurements.value) {
|
||||||
|
const pos = exportLabelPositions.get(m.id)
|
||||||
|
if (pos) drawLabelAt(outCtx, m, false, renderCtx, pos)
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.includeScaleBar) {
|
if (opts.includeScaleBar) {
|
||||||
const withBar = appendScaleBarCanvas(out, scalePxPerMmForBar)
|
const withBar = appendScaleBarCanvas(out, scalePxPerMmForBar)
|
||||||
@ -2207,6 +2416,18 @@ watch(
|
|||||||
class="font-mono text-xs text-muted-foreground"
|
class="font-mono text-xs text-muted-foreground"
|
||||||
>mm</span
|
>mm</span
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
title="Snap line + angle directions to multiples of 45°"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="snapToAngle"
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-primary"
|
||||||
|
/>
|
||||||
|
Snap 45°
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Placement hint -->
|
<!-- Placement hint -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user