feat(measurements): add rectangle tool
Fourth measurement type alongside line/ellipse/angle. 2-click placement (opposite corners → axis-aligned), corners normalised to TL/TR/BR/BL on commit then free to drag individually so the user can make the quad non-rectangular if they need to. Index ordering stays stable across drags — same as the rect *datum* in step 3. Hit test: 6 px tolerance on each of the 4 edges, plus an interior point- in-polygon fill so a big rect can be grabbed anywhere. Handles still win priority over the geometry fill so a corner grab always beats a whole-rect grab. Label at the centroid: `w × h mm · area mm²`. Width/height are means of opposing edges so reshape doesn't make readings jump; area is computed with the shoelace formula and survives non-rectangular drags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
923e969bdf
commit
a499e97361
@ -32,7 +32,7 @@ const viewOffsetX = ref(0)
|
|||||||
const viewOffsetY = ref(0)
|
const viewOffsetY = ref(0)
|
||||||
|
|
||||||
// Tool state
|
// Tool state
|
||||||
type ToolMode = "none" | "line" | "ellipse" | "angle"
|
type ToolMode = "none" | "line" | "rectangle" | "ellipse" | "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)
|
||||||
@ -48,6 +48,14 @@ interface LineMeasurement extends BaseMeasurement {
|
|||||||
a: Point
|
a: Point
|
||||||
b: Point
|
b: Point
|
||||||
}
|
}
|
||||||
|
// Corner ordering [TL, TR, BR, BL] mirrors the RectDatum convention from
|
||||||
|
// src/types/index.ts. Indices stay stable across drags even if the user
|
||||||
|
// crosses corners — visual + hit-tests don't depend on TL actually being
|
||||||
|
// top-left after reshape, matching the datum editor's behaviour.
|
||||||
|
interface RectMeasurement extends BaseMeasurement {
|
||||||
|
type: "rectangle"
|
||||||
|
corners: [Point, Point, Point, Point]
|
||||||
|
}
|
||||||
interface EllipseMeasurement extends BaseMeasurement {
|
interface EllipseMeasurement extends BaseMeasurement {
|
||||||
type: "ellipse"
|
type: "ellipse"
|
||||||
center: Point
|
center: Point
|
||||||
@ -60,7 +68,11 @@ interface AngleMeasurement extends BaseMeasurement {
|
|||||||
armA: Point
|
armA: Point
|
||||||
armB: Point
|
armB: Point
|
||||||
}
|
}
|
||||||
type Measurement = LineMeasurement | EllipseMeasurement | AngleMeasurement
|
type Measurement =
|
||||||
|
| LineMeasurement
|
||||||
|
| RectMeasurement
|
||||||
|
| EllipseMeasurement
|
||||||
|
| AngleMeasurement
|
||||||
|
|
||||||
const measurements = ref<Measurement[]>([])
|
const measurements = ref<Measurement[]>([])
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
@ -293,6 +305,35 @@ function angleDegrees(m: AngleMeasurement): number {
|
|||||||
return (rad * 180) / Math.PI
|
return (rad * 180) / Math.PI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Width = mean of TL→TR and BL→BR (the two "horizontal" sides under the
|
||||||
|
// stored ordering). Height = mean of TL→BL and TR→BR. This averages out
|
||||||
|
// minor non-rectangular skew the user may introduce while reshaping.
|
||||||
|
function rectDimensionsMm(m: RectMeasurement): { widthMm: number; heightMm: number } {
|
||||||
|
const [tl, tr, br, bl] = m.corners
|
||||||
|
const wTop = Math.hypot(tr.x - tl.x, tr.y - tl.y)
|
||||||
|
const wBot = Math.hypot(br.x - bl.x, br.y - bl.y)
|
||||||
|
const hLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y)
|
||||||
|
const hRight = Math.hypot(br.x - tr.x, br.y - tr.y)
|
||||||
|
return {
|
||||||
|
widthMm: (wTop + wBot) / 2 / props.scalePxPerMm,
|
||||||
|
heightMm: (hLeft + hRight) / 2 / props.scalePxPerMm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shoelace area of the quadrilateral, sign-stripped — handles skewed/
|
||||||
|
// reshaped rectangles correctly. Crossed quads will give a smaller area
|
||||||
|
// (common shoelace behaviour); we accept that since we don't auto-reorder.
|
||||||
|
function rectAreaMm2(m: RectMeasurement): number {
|
||||||
|
const [p0, p1, p2, p3] = m.corners
|
||||||
|
const cross =
|
||||||
|
p0.x * p1.y - p1.x * p0.y +
|
||||||
|
p1.x * p2.y - p2.x * p1.y +
|
||||||
|
p2.x * p3.y - p3.x * p2.y +
|
||||||
|
p3.x * p0.y - p0.x * p3.y
|
||||||
|
const areaPx2 = Math.abs(cross) / 2
|
||||||
|
return areaPx2 / (props.scalePxPerMm * props.scalePxPerMm)
|
||||||
|
}
|
||||||
|
|
||||||
function formatMm(v: number): string {
|
function formatMm(v: number): string {
|
||||||
return v >= 10 ? v.toFixed(1) : v.toFixed(2)
|
return v >= 10 ? v.toFixed(1) : v.toFixed(2)
|
||||||
}
|
}
|
||||||
@ -303,10 +344,21 @@ function formatArea(v: number): string {
|
|||||||
return v.toFixed(2)
|
return v.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spec for rectangle area readout: 0 decimals when ≥ 100 mm², else 1.
|
||||||
|
function formatRectArea(v: number): string {
|
||||||
|
if (v >= 100) return v.toFixed(0)
|
||||||
|
return v.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
function measurementLabel(m: Measurement): string {
|
function measurementLabel(m: Measurement): string {
|
||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
return `${formatMm(lineLengthMm(m))} mm`
|
return `${formatMm(lineLengthMm(m))} mm`
|
||||||
}
|
}
|
||||||
|
if (m.type === "rectangle") {
|
||||||
|
const { widthMm, heightMm } = rectDimensionsMm(m)
|
||||||
|
const area = rectAreaMm2(m)
|
||||||
|
return `${widthMm.toFixed(1)} × ${heightMm.toFixed(1)} mm · ${formatRectArea(area)} mm²`
|
||||||
|
}
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
const { semiMajor, semiMinor } = ellipseAxesMm(m)
|
const { semiMajor, semiMinor } = ellipseAxesMm(m)
|
||||||
const area = Math.PI * semiMajor * semiMinor
|
const area = Math.PI * semiMajor * semiMinor
|
||||||
@ -317,16 +369,34 @@ function measurementLabel(m: Measurement): string {
|
|||||||
|
|
||||||
function measurementTypeLabel(m: Measurement): string {
|
function measurementTypeLabel(m: Measurement): string {
|
||||||
if (m.type === "line") return "Line"
|
if (m.type === "line") return "Line"
|
||||||
|
if (m.type === "rectangle") return "Rect"
|
||||||
if (m.type === "ellipse") return "Ellipse"
|
if (m.type === "ellipse") return "Ellipse"
|
||||||
return "Angle"
|
return "Angle"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Side-panel summary uses the shorter "w×h mm" without the area suffix per
|
||||||
|
// the spec — a separate format from the on-canvas label.
|
||||||
|
function measurementSummaryValue(m: Measurement): string {
|
||||||
|
if (m.type === "rectangle") {
|
||||||
|
const { widthMm, heightMm } = rectDimensionsMm(m)
|
||||||
|
return `${widthMm.toFixed(1)}×${heightMm.toFixed(1)} mm`
|
||||||
|
}
|
||||||
|
return measurementLabel(m)
|
||||||
|
}
|
||||||
|
|
||||||
// Anchor point in image space where we place the label. Chosen per type so
|
// Anchor point in image space where we place the label. Chosen per type so
|
||||||
// the label sits in a predictable, non-occluding position.
|
// the label sits in a predictable, non-occluding position.
|
||||||
function labelAnchor(m: Measurement): Point {
|
function labelAnchor(m: Measurement): Point {
|
||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
|
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
|
||||||
}
|
}
|
||||||
|
if (m.type === "rectangle") {
|
||||||
|
const [p0, p1, p2, p3] = m.corners
|
||||||
|
return {
|
||||||
|
x: (p0.x + p1.x + p2.x + p3.x) / 4,
|
||||||
|
y: (p0.y + p1.y + p2.y + p3.y) / 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
return m.center
|
return m.center
|
||||||
}
|
}
|
||||||
@ -370,6 +440,8 @@ function drawMeasurement(
|
|||||||
|
|
||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
||||||
|
} else if (m.type === "rectangle") {
|
||||||
|
drawRectGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
||||||
} else if (m.type === "ellipse") {
|
} else if (m.type === "ellipse") {
|
||||||
drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
||||||
} else {
|
} else {
|
||||||
@ -403,6 +475,35 @@ function drawLineGeometry(
|
|||||||
drawHandle(ctx, sb, handleColor, isSelected)
|
drawHandle(ctx, sb, handleColor, isSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawRectGeometry(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
m: RectMeasurement,
|
||||||
|
strokeColor: string,
|
||||||
|
handleColor: string,
|
||||||
|
lineWidth: number,
|
||||||
|
isSelected: boolean,
|
||||||
|
) {
|
||||||
|
const screenCorners = m.corners.map(imgToScreen)
|
||||||
|
ctx.beginPath()
|
||||||
|
for (let i = 0; i < screenCorners.length; i++) {
|
||||||
|
const p = screenCorners[i]
|
||||||
|
if (!p) continue
|
||||||
|
if (i === 0) ctx.moveTo(p.x, p.y)
|
||||||
|
else ctx.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.strokeStyle = strokeColor
|
||||||
|
ctx.lineWidth = lineWidth
|
||||||
|
ctx.setLineDash(isSelected ? [] : [6, 3])
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
// Don't fill the interior — keeps what's underneath visible, matching
|
||||||
|
// the line/ellipse/angle visual style.
|
||||||
|
for (const p of screenCorners) {
|
||||||
|
drawHandle(ctx, p, handleColor, isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function drawEllipseGeometry(
|
function drawEllipseGeometry(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
m: EllipseMeasurement,
|
m: EllipseMeasurement,
|
||||||
@ -612,6 +713,12 @@ function drawPlacementPreview(ctx: CanvasRenderingContext2D) {
|
|||||||
ctx.moveTo(sPts[0].x, sPts[0].y)
|
ctx.moveTo(sPts[0].x, sPts[0].y)
|
||||||
ctx.lineTo(sCursor.x, sCursor.y)
|
ctx.lineTo(sCursor.x, sCursor.y)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
} else if (activeTool.value === "rectangle" && sPts.length >= 1 && sPts[0] && sCursor) {
|
||||||
|
const a = sPts[0]
|
||||||
|
const b = sCursor
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.rect(a.x, a.y, b.x - a.x, b.y - a.y)
|
||||||
|
ctx.stroke()
|
||||||
} else if (activeTool.value === "ellipse" && sPts.length >= 1 && sPts[0]) {
|
} else if (activeTool.value === "ellipse" && sPts.length >= 1 && sPts[0]) {
|
||||||
const center = sPts[0]
|
const center = sPts[0]
|
||||||
const endA = sPts[1] ?? sCursor
|
const endA = sPts[1] ?? sCursor
|
||||||
@ -700,6 +807,24 @@ function pointToSegmentDistance(
|
|||||||
return Math.hypot(p.x - qx, p.y - qy)
|
return Math.hypot(p.x - qx, p.y - qy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard ray-cast point-in-polygon. Works for any simple quadrilateral
|
||||||
|
// (including reshaped non-rect cases) and gracefully degrades to a small
|
||||||
|
// or zero region for crossed quads — which is what we want, since a
|
||||||
|
// "crossed" rectangle is effectively user error.
|
||||||
|
function pointInPolygon(p: Point, poly: Point[]): boolean {
|
||||||
|
let inside = false
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i++) {
|
||||||
|
const pi = poly[i]
|
||||||
|
const pj = poly[j]
|
||||||
|
if (!pi || !pj) continue
|
||||||
|
const intersect =
|
||||||
|
pi.y > p.y !== pj.y > p.y &&
|
||||||
|
p.x < ((pj.x - pi.x) * (p.y - pi.y)) / (pj.y - pi.y) + pi.x
|
||||||
|
if (intersect) inside = !inside
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the min screen-space distance from cursor to the ellipse curve.
|
// Returns the min screen-space distance from cursor to the ellipse curve.
|
||||||
// Sampled parametrically; 96 samples is overkill for hit-testing but cheap.
|
// Sampled parametrically; 96 samples is overkill for hit-testing but cheap.
|
||||||
function ellipseCurveDistance(
|
function ellipseCurveDistance(
|
||||||
@ -743,6 +868,14 @@ function getHandlePositions(m: Measurement): { key: string; pt: Point }[] {
|
|||||||
{ key: "b", pt: m.b },
|
{ key: "b", pt: m.b },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
if (m.type === "rectangle") {
|
||||||
|
return [
|
||||||
|
{ key: "c0", pt: m.corners[0] },
|
||||||
|
{ key: "c1", pt: m.corners[1] },
|
||||||
|
{ key: "c2", pt: m.corners[2] },
|
||||||
|
{ key: "c3", pt: m.corners[3] },
|
||||||
|
]
|
||||||
|
}
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
return [
|
return [
|
||||||
{ key: "center", pt: m.center },
|
{ key: "center", pt: m.center },
|
||||||
@ -803,6 +936,23 @@ function hitTest(cursorScreen: Point): HitResult | null {
|
|||||||
if (pointToSegmentDistance(cursorScreen, sa, sb) <= LINE_HIT_PX) {
|
if (pointToSegmentDistance(cursorScreen, sa, sb) <= LINE_HIT_PX) {
|
||||||
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
||||||
}
|
}
|
||||||
|
} else if (m.type === "rectangle") {
|
||||||
|
const sc = m.corners.map(imgToScreen)
|
||||||
|
// Edge-near test for thin grabs along the border.
|
||||||
|
let edgeHit = false
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const a = sc[i]
|
||||||
|
const b = sc[(i + 1) % 4]
|
||||||
|
if (a && b && pointToSegmentDistance(cursorScreen, a, b) <= LINE_HIT_PX) {
|
||||||
|
edgeHit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Interior-fill test so a big rect is grabbable from anywhere
|
||||||
|
// inside, not just along the 6px edge band.
|
||||||
|
if (edgeHit || pointInPolygon(cursorScreen, sc)) {
|
||||||
|
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
||||||
|
}
|
||||||
} else if (m.type === "ellipse") {
|
} else if (m.type === "ellipse") {
|
||||||
if (ellipseCurveDistance(cursorScreen, m) <= ELLIPSE_HIT_PX) {
|
if (ellipseCurveDistance(cursorScreen, m) <= ELLIPSE_HIT_PX) {
|
||||||
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
||||||
@ -838,6 +988,28 @@ function commitPlacement() {
|
|||||||
colorCounter += 1
|
colorCounter += 1
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
placementPoints.value = []
|
placementPoints.value = []
|
||||||
|
} else if (activeTool.value === "rectangle" && pts.length === 2) {
|
||||||
|
const [p1, p2] = pts as [Point, Point]
|
||||||
|
// Normalise so corners are TL/TR/BR/BL regardless of click order.
|
||||||
|
const minX = Math.min(p1.x, p2.x)
|
||||||
|
const maxX = Math.max(p1.x, p2.x)
|
||||||
|
const minY = Math.min(p1.y, p2.y)
|
||||||
|
const maxY = Math.max(p1.y, p2.y)
|
||||||
|
const id = nanoid()
|
||||||
|
measurements.value.push({
|
||||||
|
id,
|
||||||
|
type: "rectangle",
|
||||||
|
colorIndex: colorCounter,
|
||||||
|
corners: [
|
||||||
|
{ x: minX, y: minY },
|
||||||
|
{ x: maxX, y: minY },
|
||||||
|
{ x: maxX, y: maxY },
|
||||||
|
{ x: minX, y: maxY },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
colorCounter += 1
|
||||||
|
selectedId.value = id
|
||||||
|
placementPoints.value = []
|
||||||
} else if (activeTool.value === "ellipse" && pts.length === 3) {
|
} else if (activeTool.value === "ellipse" && pts.length === 3) {
|
||||||
const [center, axisEndA, axisEndB] = pts as [Point, Point, Point]
|
const [center, axisEndA, axisEndB] = pts as [Point, Point, Point]
|
||||||
const id = nanoid()
|
const id = nanoid()
|
||||||
@ -872,7 +1044,8 @@ function commitPlacement() {
|
|||||||
function handlePlacementClick(imgPt: Point) {
|
function handlePlacementClick(imgPt: Point) {
|
||||||
if (activeTool.value === "none") return
|
if (activeTool.value === "none") return
|
||||||
placementPoints.value.push(imgPt)
|
placementPoints.value.push(imgPt)
|
||||||
const needed = activeTool.value === "line" ? 2 : 3
|
const needed =
|
||||||
|
activeTool.value === "line" || activeTool.value === "rectangle" ? 2 : 3
|
||||||
if (placementPoints.value.length >= needed) {
|
if (placementPoints.value.length >= needed) {
|
||||||
commitPlacement()
|
commitPlacement()
|
||||||
}
|
}
|
||||||
@ -919,6 +1092,17 @@ function cloneMeasurement(m: Measurement): Measurement {
|
|||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
return { ...m, a: { ...m.a }, b: { ...m.b } }
|
return { ...m, a: { ...m.a }, b: { ...m.b } }
|
||||||
}
|
}
|
||||||
|
if (m.type === "rectangle") {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
corners: [
|
||||||
|
{ ...m.corners[0] },
|
||||||
|
{ ...m.corners[1] },
|
||||||
|
{ ...m.corners[2] },
|
||||||
|
{ ...m.corners[3] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
@ -950,6 +1134,17 @@ function applyDrag(
|
|||||||
b: { x: original.b.x + dx, y: original.b.y + dy },
|
b: { x: original.b.x + dx, y: original.b.y + dy },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (original.type === "rectangle") {
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
corners: [
|
||||||
|
{ x: original.corners[0].x + dx, y: original.corners[0].y + dy },
|
||||||
|
{ x: original.corners[1].x + dx, y: original.corners[1].y + dy },
|
||||||
|
{ x: original.corners[2].x + dx, y: original.corners[2].y + dy },
|
||||||
|
{ x: original.corners[3].x + dx, y: original.corners[3].y + dy },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
if (original.type === "ellipse") {
|
if (original.type === "ellipse") {
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
@ -969,6 +1164,28 @@ function applyDrag(
|
|||||||
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") return { ...original, a: { x: original.a.x + dx, y: original.a.y + dy } }
|
||||||
if (handleKey === "b") return { ...original, b: { x: original.b.x + dx, y: original.b.y + dy } }
|
if (handleKey === "b") return { ...original, b: { x: original.b.x + dx, y: original.b.y + dy } }
|
||||||
|
} else if (original.type === "rectangle") {
|
||||||
|
// Per spec: dragging an individual corner only moves that corner —
|
||||||
|
// the rect can become non-rectangular. The array index stays
|
||||||
|
// stable so TL/TR/BR/BL labels don't shift.
|
||||||
|
const cornerIdx: 0 | 1 | 2 | 3 | null =
|
||||||
|
handleKey === "c0" ? 0 :
|
||||||
|
handleKey === "c1" ? 1 :
|
||||||
|
handleKey === "c2" ? 2 :
|
||||||
|
handleKey === "c3" ? 3 : null
|
||||||
|
if (cornerIdx !== null) {
|
||||||
|
const next: [Point, Point, Point, Point] = [
|
||||||
|
{ ...original.corners[0] },
|
||||||
|
{ ...original.corners[1] },
|
||||||
|
{ ...original.corners[2] },
|
||||||
|
{ ...original.corners[3] },
|
||||||
|
]
|
||||||
|
next[cornerIdx] = {
|
||||||
|
x: original.corners[cornerIdx].x + dx,
|
||||||
|
y: original.corners[cornerIdx].y + dy,
|
||||||
|
}
|
||||||
|
return { ...original, corners: next }
|
||||||
|
}
|
||||||
} else if (original.type === "ellipse") {
|
} else if (original.type === "ellipse") {
|
||||||
if (handleKey === "center") {
|
if (handleKey === "center") {
|
||||||
// Dragging the ellipse center translates the whole ellipse so
|
// Dragging the ellipse center translates the whole ellipse so
|
||||||
@ -1266,6 +1483,10 @@ const placementHint = computed<string | null>(() => {
|
|||||||
if (n === 0) return "Click the first endpoint."
|
if (n === 0) return "Click the first endpoint."
|
||||||
return "Click the second endpoint."
|
return "Click the second endpoint."
|
||||||
}
|
}
|
||||||
|
if (activeTool.value === "rectangle") {
|
||||||
|
if (n === 0) return "Click the first corner."
|
||||||
|
return "Click the opposite corner."
|
||||||
|
}
|
||||||
if (activeTool.value === "ellipse") {
|
if (activeTool.value === "ellipse") {
|
||||||
if (n === 0) return "Click the ellipse center."
|
if (n === 0) return "Click the ellipse center."
|
||||||
if (n === 1) return "Click the first semi-axis endpoint."
|
if (n === 1) return "Click the first semi-axis endpoint."
|
||||||
@ -1281,7 +1502,7 @@ const measurementSummaries = computed(() => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
type: m.type,
|
type: m.type,
|
||||||
typeLabel: measurementTypeLabel(m),
|
typeLabel: measurementTypeLabel(m),
|
||||||
label: measurementLabel(m),
|
label: measurementSummaryValue(m),
|
||||||
color: getDatumColor(m.colorIndex),
|
color: getDatumColor(m.colorIndex),
|
||||||
selected: m.id === selectedId.value,
|
selected: m.id === selectedId.value,
|
||||||
}))
|
}))
|
||||||
@ -1454,6 +1675,30 @@ watch(() => props.scalePxPerMm, () => { drawOverlay() })
|
|||||||
</svg>
|
</svg>
|
||||||
Ellipse
|
Ellipse
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
activeTool === 'rectangle'
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
"
|
||||||
|
@click="setTool('rectangle')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="4" y="6" width="16" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
Rect
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
|
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
|
||||||
:class="
|
:class="
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user