diff --git a/src/components/MapStartbox.tsx b/src/components/MapStartbox.tsx index 44c299ab1..014949f1a 100644 --- a/src/components/MapStartbox.tsx +++ b/src/components/MapStartbox.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useRef, useCallback } from "react"; import DeleteIcon from "@mui/icons-material/Delete"; +import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import AddIcon from "@mui/icons-material/Add"; import SaveAltIcon from "@mui/icons-material/SaveAlt"; import FullscreenIcon from "@mui/icons-material/Fullscreen"; @@ -13,150 +14,33 @@ import { DialogTitle, DialogContent, Stack, + Slider, + Typography, + Popover, + Button, } from "@mui/material"; -export interface Point { - x: number; - y: number; -} - -function pointEqual(a: Point, b: Point): boolean { - return a.x === b.x && a.y === b.y; -} - -export interface Startbox { - poly: [Point, Point]; -} - -function startboxEqual(a: Startbox, b: Startbox): boolean { - return pointEqual(a.poly[0], b.poly[0]) && pointEqual(a.poly[1], b.poly[1]); -} - -function getStartboxString(startbox: Startbox): string { - return startbox.poly.map((point) => `${point.x} ${point.y}`).join(" "); -} - -function parseStartboxString(startboxString: string): Startbox { - const coords = startboxString - .trim() - .split(/ +/) - .map((field) => { - const val = parseInt(field, 10); - if (isNaN(val)) { - throw new Error(`'${field}' is not a number`); - } - if (val >= 0 && val <= 200) { - return val; - } - throw new Error(`${val} not in range 0-200`); - }); - if (coords.length !== 4) { - throw new Error(`must have 4 coords`); - } - const box: Startbox = { - poly: [ - { x: coords[0], y: coords[1] }, - { x: coords[2], y: coords[3] }, - ], - }; - if (box.poly[0].x >= box.poly[1].x) { - throw new Error(`x₁ (${box.poly[0].x}) must be < x₂ (${box.poly[1].x})`); - } - if (box.poly[0].y >= box.poly[1].y) { - throw new Error(`y₁ (${box.poly[0].y}) must be < y₂ (${box.poly[1].y})`); - } - return box; -} - -// Clamps val between min and max -function clamp(min: number, val: number, max: number): number { - return Math.min(Math.max(val, min), max); -} - -// Immutable state object for single startbox -class StartboxState { - public readonly str: string; - - constructor( - public readonly box: Startbox, - str?: string, - public readonly strErr?: string - ) { - if (str !== undefined) { - this.str = str; - } else { - this.str = getStartboxString(box); - } - } - - setStr(str: string): StartboxState { - if (str === this.str) { - return this; - } - try { - return new StartboxState(parseStartboxString(str), str); - } catch (e) { - return new StartboxState(this.box, str, (e as Error).message); - } - } - - private clampTopLeft({ x, y }: Point): Point { - return { - x: clamp(0, Math.round(x), this.box.poly[1].x - 2), - y: clamp(0, Math.round(y), this.box.poly[1].y - 2), - }; - } - - private clampBottomRight({ x, y }: Point): Point { - return { - x: clamp(this.box.poly[0].x + 2, Math.round(x), 200), - y: clamp(this.box.poly[0].y + 2, Math.round(y), 200), - }; - } - - setPoint(pointIndex: 0 | 1, point: Point): StartboxState { - const newBox: Startbox = { - poly: [...this.box.poly], - }; - if (pointIndex === 0) { - newBox.poly[0] = this.clampTopLeft(point); - } else { - newBox.poly[1] = this.clampBottomRight(point); - } - if (startboxEqual(this.box, newBox)) { - return this; - } - return new StartboxState(newBox); - } -} - -// Immutable state object for all startboxes -class StartboxesState { - constructor(public readonly boxes: StartboxState[]) {} - - update( - idx: number, - update: (box: StartboxState) => StartboxState - ): StartboxesState { - const newBox = update(this.boxes[idx]); - if (newBox === this.boxes[idx]) { - return this; - } - const newStartboxes = [...this.boxes]; - newStartboxes[idx] = newBox; - return new StartboxesState(newStartboxes); - } - - add(box: Startbox): StartboxesState { - return new StartboxesState([...this.boxes, new StartboxState(box)]); - } - - remove(idx: number): StartboxesState { - const newStartboxes = [...this.boxes]; - newStartboxes.splice(idx, 1); - return new StartboxesState(newStartboxes); - } -} +import { + Point, + Startbox, + STRENGTH_STEP, + snapStrength, + formatStrength, + startboxEqual, + isRectangle, + polygonCentroid, + curveMidpoint, + insertionStrength, + popoverOriginsFor, + tessellatedPathString, + strengthVisuals, +} from "./startbox/geometry"; +import { loadPoly, savePoly } from "./startbox/serialization"; +import { StartboxState, StartboxesState } from "./startbox/state"; +import { useStartboxDraw } from "./startbox/useStartboxDraw"; +import CreateStartboxMenu from "./startbox/CreateStartboxMenu"; + +export type { Startbox } from "./startbox/geometry"; export interface MapStartboxProps { textureUrl: string; @@ -165,35 +49,83 @@ export interface MapStartboxProps { editable?: boolean; } -const NEW_STARTBOX: Startbox = { - poly: [ - { x: 50, y: 50 }, - { x: 150, y: 150 }, - ], -}; +interface SelectedVertex { + startboxIndex: number; + vertexIndex: number; + anchorEl: SVGCircleElement; +} + +// Pixel distance below which a mousedown/mouseup pair is treated as a click +// (open the strength popover) rather than the start of a drag. +const CLICK_DRAG_THRESHOLD = 3; export default function MapStartbox(props: MapStartboxProps) { const [dialogOpen, setDialogOpen] = useState(false); const initStartboxes = props.startboxes || []; const [startboxes, setStartboxes] = useState( - new StartboxesState(initStartboxes.map((box) => new StartboxState(box))) + new StartboxesState( + initStartboxes.map((box) => new StartboxState(loadPoly(box))) + ) ); - const selectedElement = useRef<{ + const selectedElement = useRef< + | { type: "vertex"; startboxIndex: number; vertexIndex: number } + | { type: "move"; startboxIndex: number; origin: Point } + | null + >(null); + + const draw = useStartboxDraw((poly) => + setStartboxes((prev) => prev.add(poly)) + ); + const [createAnchor, setCreateAnchor] = useState(null); + + const [deleteStartbox, setDeleteStartbox] = useState(false); + const [deleteVertex, setDeleteVertex] = useState(false); + const [selectedVertex, setSelectedVertex] = useState( + null + ); + + // While a vertex is mouse-down'd we track the press as either a pending + // click or a confirmed drag. If the cursor moves more than CLICK_DRAG_THRESHOLD + // pixels before mouseup, the press becomes a drag and the popover stays + // closed; otherwise mouseup opens the strength popover anchored at the + // vertex's . + const pendingClick = useRef<{ startboxIndex: number; - pointIndex: 0 | 1; + vertexIndex: number; + anchorEl: SVGCircleElement; + clientX: number; + clientY: number; } | null>(null); - const [deleteStartbox, setDeleteStartbox] = useState(false); + // Clear selection if it points at a vertex that no longer exists + useEffect(() => { + if (selectedVertex === null) return; + const sb = startboxes.boxes[selectedVertex.startboxIndex]; + if (!sb || selectedVertex.vertexIndex >= sb.poly.length) { + setSelectedVertex(null); + } + }, [startboxes, selectedVertex]); + + // While drawing, Escape cancels and Enter commits the in-progress shape. + useEffect(() => { + if (draw.mode === null) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") draw.cancel(); + else if (e.key === "Enter") draw.commit(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [draw]); const changedStartbox = initStartboxes.length !== startboxes.boxes.length || initStartboxes.some( - (box, idx) => !startboxEqual(box, startboxes.boxes[idx].box) + (box, idx) => !startboxEqual(savePoly(startboxes.boxes[idx].poly), box) ); function saveStartboxes() { if (props.updatedStartboxes) { - props.updatedStartboxes(startboxes.boxes.map((sb) => sb.box)); + props.updatedStartboxes(startboxes.boxes.map((sb) => savePoly(sb.poly))); } } @@ -204,20 +136,67 @@ export default function MapStartbox(props: MapStartboxProps) { } } - function mouseMove(event: React.MouseEvent) { - if (selectedElement.current === null) return; - event.preventDefault(); - const svg = event.currentTarget; + function svgPoint(event: React.MouseEvent): Point { + const svg = + event.currentTarget instanceof SVGSVGElement + ? event.currentTarget + : event.currentTarget.ownerSVGElement!; const ctm = svg.getScreenCTM()!; - const point = { + return { x: (event.clientX - ctm.e) / ctm.a, y: (event.clientY - ctm.f) / ctm.d, }; - setStartboxes( - startboxes.update(selectedElement.current.startboxIndex, (sb) => - sb.setPoint(selectedElement.current!.pointIndex, point) - ) - ); + } + + function mouseMove(event: React.MouseEvent) { + // If the user moves the cursor beyond the click threshold while a vertex + // mouse-down is still pending, promote it to a drag and cancel the + // pending popover-open. + if (pendingClick.current !== null) { + const dx = event.clientX - pendingClick.current.clientX; + const dy = event.clientY - pendingClick.current.clientY; + if (dx * dx + dy * dy >= CLICK_DRAG_THRESHOLD * CLICK_DRAG_THRESHOLD) { + pendingClick.current = null; + } + } + + if (selectedElement.current === null) return; + event.preventDefault(); + const point = svgPoint(event); + if (selectedElement.current.type === "vertex") { + const vertexIndex = (selectedElement.current as any).vertexIndex; + const sbIndex = selectedElement.current.startboxIndex; + setStartboxes( + startboxes.update(sbIndex, (sb) => + isRectangle(sb.poly) + ? sb.resizeRectCorner(vertexIndex, point) + : sb.setVertex(vertexIndex, point) + ) + ); + } else if (selectedElement.current.type === "move") { + const dx = point.x - selectedElement.current.origin.x; + const dy = point.y - selectedElement.current.origin.y; + setStartboxes( + startboxes.update(selectedElement.current.startboxIndex, (sb) => + sb.moveBy(dx, dy) + ) + ); + selectedElement.current = { ...selectedElement.current, origin: point }; + } + } + + function endInteraction() { + if (pendingClick.current !== null) { + // The press never moved — treat it as a click, open the popover. + const pc = pendingClick.current; + setSelectedVertex({ + startboxIndex: pc.startboxIndex, + vertexIndex: pc.vertexIndex, + anchorEl: pc.anchorEl, + }); + pendingClick.current = null; + } + selectedElement.current = null; } const [textureAspectRatio, setTextureAspectRatio] = useState(0); @@ -241,10 +220,6 @@ export default function MapStartbox(props: MapStartboxProps) { const mapView = ( <>
- {/* We add image here so that it's going to properly set the aspect ratio of the parent div - and then the SVG is instructed to just fill the full space. This trick allows for the - viewport coordinates in SVG to be always exactly 0 0 200 200 while maintaining the aspect - ratio of the map. */} imageElementAspectRatio ? { width: "100%", maxHeight: "100%" } : { height: "100%", maxWidth: "100%" }), + ...(draw.mode ? { cursor: "crosshair" } : {}), + }} + onMouseLeave={endInteraction} + onMouseDown={(e) => { + if (draw.mode) { + e.preventDefault(); + draw.onMouseDown(svgPoint(e)); + } }} - onMouseLeave={() => { - selectedElement.current = null; + onMouseUp={(e) => { + if (draw.mode) draw.onMouseUp(svgPoint(e)); + else endInteraction(); }} - onMouseUp={() => { - selectedElement.current = null; + onMouseMove={(e) => { + if (draw.mode) draw.onMouseMove(svgPoint(e)); + else mouseMove(e); + }} + onClick={() => { + setDeleteStartbox(false); + setDeleteVertex(false); }} - onMouseMove={mouseMove} - onClick={() => setDeleteStartbox(false)} > - {startboxes.boxes.map((startbox, startboxIndex) => { - const [start, end] = startbox.box.poly; - const width = end.x - start.x; - const height = end.y - start.y; - return ( - - + {startboxes.boxes.map((startbox, startboxIndex) => { + const poly = startbox.poly; + const pathStr = tessellatedPathString(poly); + const center = polygonCentroid(poly); + return ( + + maybeDeleteStartbox(startboxIndex)} + onMouseDown={(e) => { + if (deleteStartbox || deleteVertex || !props.editable) + return; + const origin = svgPoint(e); + selectedElement.current = { + type: "move", + startboxIndex, + origin, + }; + }} + /> + + {startboxIndex + 1} + + {props.editable && ( + <> + {/* Vertex drag handles, sized/coloured by strength */} + {poly.map((point, vertexIndex) => { + const s = point.strength ?? 0; + const vis = deleteVertex + ? { fill: "#ff6666", r: 2.5 } + : strengthVisuals(s); + const isSelected = + selectedVertex !== null && + selectedVertex.startboxIndex === startboxIndex && + selectedVertex.vertexIndex === vertexIndex; + return ( + + {isSelected && ( + + )} + { + if (deleteVertex) { + e.stopPropagation(); + if (poly.length > 3) { + setStartboxes( + startboxes.update(startboxIndex, (sb) => + sb.removeVertex(vertexIndex) + ) + ); + } + setDeleteVertex(false); + return; + } + e.stopPropagation(); + pendingClick.current = { + startboxIndex, + vertexIndex, + anchorEl: e.currentTarget, + clientX: e.clientX, + clientY: e.clientY, + }; + selectedElement.current = { + type: "vertex", + startboxIndex, + vertexIndex, + }; + }} + > + + {`vertex ${ + vertexIndex + 1 + } — strength ${formatStrength(s)}`} + + + + ); + })} + {/* Edge insert handles, riding the rendered curve. */} + {poly.map((_, i) => { + const mid = curveMidpoint(poly, i); + const newStrength = insertionStrength(poly, i); + const newPoint: Point = { + x: mid.x, + y: mid.y, + }; + if (newStrength > 0) newPoint.strength = newStrength; + return ( + { + e.stopPropagation(); + setStartboxes( + startboxes.update(startboxIndex, (sb) => + sb.insertVertex(i, newPoint) + ) + ); + }} + /> + ); + })} + + )} + + ); + })} + + {draw.preview && ( + + {draw.preview.closed ? ( + `${p.x},${p.y}`) + .join(" ")} + fill="rgba(0, 150, 255, 0.12)" + stroke="deepskyblue" + strokeWidth="0.7" + strokeDasharray="2 2" + /> + ) : ( + `${p.x},${p.y}`) + .join(" ")} + fill="none" + stroke="deepskyblue" + strokeWidth="0.7" + strokeDasharray="2 2" + /> + )} + {draw.preview.points.map((p, i) => ( + maybeDeleteStartbox(startboxIndex)} /> - - {startboxIndex + 1} - - {props.editable && - startbox.box.poly.map((point, pointIndex) => ( - { - selectedElement.current = { - startboxIndex, - pointIndex: pointIndex as 0 | 1, - }; - }} - /> - ))} - - ); - })} + ))} + + )}
); if (props.editable) { + const selectedSb = + selectedVertex !== null + ? startboxes.boxes[selectedVertex.startboxIndex] + : null; + const selectedStrength = + selectedSb !== null + ? selectedSb.poly[selectedVertex!.vertexIndex].strength ?? 0 + : 0; + const popoverOrigins = + selectedSb !== null + ? popoverOriginsFor(selectedSb.poly, selectedVertex!.vertexIndex) + : null; + const editorView = ( <> setStartboxes(startboxes.add(NEW_STARTBOX))} + onClick={(e) => setCreateAnchor(e.currentTarget)} > @@ -367,12 +509,29 @@ export default function MapStartbox(props: MapStartboxProps) { setDeleteStartbox(!deleteStartbox)} + onClick={() => { + setDeleteStartbox(!deleteStartbox); + setDeleteVertex(false); + }} > + + + { + setDeleteVertex(!deleteVertex); + setDeleteStartbox(false); + }} + > + + + + {props.updatedStartboxes && ( @@ -397,16 +556,116 @@ export default function MapStartbox(props: MapStartboxProps) { -
+ setCreateAnchor(null)} + onSelect={(m) => draw.start(m)} + /> +
{mapView}
+ + {/* Strength popover, anchored at the clicked vertex's . + Slider snaps to step 0.05 with explicit snap-to-0 below 0.025 + and snap-to-1 above 0.975 so map makers can't accidentally store + strength=0.03 by nudging the slider. */} + setSelectedVertex(null)} + anchorOrigin={ + popoverOrigins?.anchorOrigin ?? { + vertical: "bottom", + horizontal: "right", + } + } + transformOrigin={ + popoverOrigins?.transformOrigin ?? { + vertical: "top", + horizontal: "left", + } + } + disableRestoreFocus + PaperProps={{ sx: { p: 1.5, minWidth: 240 } }} + > + {selectedVertex !== null && selectedSb !== null && ( + <> + + + {`Box ${selectedVertex.startboxIndex + 1} · vertex ${ + selectedVertex.vertexIndex + 1 + }`} + + + {`Strength: ${formatStrength(selectedStrength)}`} + + { + const v = Array.isArray(raw) ? raw[0] : raw; + setStartboxes( + startboxes.update(selectedVertex.startboxIndex, (sb) => + sb.setVertexStrength( + selectedVertex.vertexIndex, + v as number + ) + ) + ); + }} + valueLabelDisplay="auto" + valueLabelFormat={(v) => formatStrength(snapStrength(v))} + // Pull the absolutely-positioned mark labels (0, 0.5, 1) + // closer to the track so they fit inside the popover's + // content flow. Default is 30px; 23px tucks them right + // beneath the track without overlapping the thumb area. + sx={{ "& .MuiSlider-markLabel": { top: "23px" } }} + /> + + {/* Button is a sibling of (not a child of) the Stack so the + Stack's auto-injected margin-top rule (which has higher + CSS specificity than the sx prop on a child) doesn't + override the spacing we want here. */} + + + )} + + {startboxes.boxes.map((startbox, startboxIndex) => ( setStartboxes( startboxes.update(startboxIndex, (sb) => @@ -445,7 +704,13 @@ export default function MapStartbox(props: MapStartboxProps) { ); } else { return ( -
+
{editorView}
); diff --git a/src/components/startbox/CreateStartboxMenu.tsx b/src/components/startbox/CreateStartboxMenu.tsx new file mode 100644 index 000000000..904c54920 --- /dev/null +++ b/src/components/startbox/CreateStartboxMenu.tsx @@ -0,0 +1,50 @@ +import { + Popover, + MenuList, + MenuItem, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import CropSquareIcon from "@mui/icons-material/CropSquare"; +import ChangeHistoryIcon from "@mui/icons-material/ChangeHistory"; + +import { DrawMode } from "./useStartboxDraw"; + +export interface CreateStartboxMenuProps { + anchorEl: HTMLElement | null; + open: boolean; + onClose: () => void; + onSelect: (mode: DrawMode) => void; +} + +export default function CreateStartboxMenu(props: CreateStartboxMenuProps) { + function pick(mode: DrawMode) { + props.onSelect(mode); + props.onClose(); + } + + return ( + + + pick("rect")}> + + + + Rectangle + + pick("polygon")}> + + + + Polygon + + + + ); +} diff --git a/src/components/startbox/geometry.ts b/src/components/startbox/geometry.ts new file mode 100644 index 000000000..fc6b1b260 --- /dev/null +++ b/src/components/startbox/geometry.ts @@ -0,0 +1,338 @@ +export interface Point { + x: number; + y: number; + /** + * Optional Catmull-Rom spline strength in [0, 1]. + * 0 (default) = sharp polygon corner + * 1 = full smooth curve + * Per-edge tension is the average of the two endpoint anchor strengths. + */ + strength?: number; +} + +export function pointEqual(a: Point, b: Point): boolean { + return a.x === b.x && a.y === b.y && (a.strength ?? 0) === (b.strength ?? 0); +} + +export interface Startbox { + poly: Point[]; +} + +export const NEW_POLYGON: Point[] = [ + { x: 50, y: 50 }, + { x: 150, y: 50 }, + { x: 150, y: 150 }, + { x: 50, y: 150 }, +]; + +export function startboxEqual(a: Startbox, b: Startbox): boolean { + if (a.poly.length !== b.poly.length) return false; + return a.poly.every((p, i) => pointEqual(p, b.poly[i])); +} + +// Legacy 2-point rectangle to 4-point polygon (for editor state only). +export function rectToPolygon(poly: [Point, Point]): Point[] { + const [tl, br] = poly; + return [ + { x: tl.x, y: tl.y }, + { x: br.x, y: tl.y }, + { x: br.x, y: br.y }, + { x: tl.x, y: br.y }, + ]; +} + +export function isLegacyRect(poly: Point[]): boolean { + return poly.length === 2; +} + +// True when the polygon is an axis-aligned rectangle (the editor should +// resize it as a rect rather than as free vertices). +export function isRectangle(poly: Point[]): boolean { + if (poly.length === 2) return true; + if (poly.length !== 4) return false; + if (poly.some((p) => (p.strength ?? 0) > 0)) return false; + const xs = [...poly.map((p) => p.x)].sort((a, b) => a - b); + const ys = [...poly.map((p) => p.y)].sort((a, b) => a - b); + return ( + xs[0] === xs[1] && xs[2] === xs[3] && ys[0] === ys[1] && ys[2] === ys[3] + ); +} + +// Convert polygon back to 2-point rectangle if it's an axis-aligned rect with +// no per-anchor strengths (i.e. a plain polygon that happens to be a rectangle). +export function tryPolygonToRect(poly: Point[]): Point[] { + if (poly.length !== 4) return poly; + if (poly.some((p) => (p.strength ?? 0) > 0)) return poly; + const xs = poly.map((p) => p.x).sort((a, b) => a - b); + const ys = poly.map((p) => p.y).sort((a, b) => a - b); + const isRect = + xs[0] === xs[1] && xs[2] === xs[3] && ys[0] === ys[1] && ys[2] === ys[3]; + if (!isRect) return poly; + return [ + { x: xs[0], y: ys[0] }, + { x: xs[3], y: ys[3] }, + ]; +} + +// Snap strength to step 0.025, with explicit snap-to-0 below half a step and +// snap-to-1 above 1 - half a step so map makers don't accidentally keep tiny +// non-zero strengths or near-1 strengths that aren't quite 1. +// +// We round in integer space (multiply, round, divide by the denominator) so +// the result lands on an exact float value rather than something like +// 0.30000000000000004 that would otherwise leak from `s / 0.025 * 0.025`. +export const STRENGTH_STEP = 0.025; +export const STRENGTH_DENOM = 40; // 1 / STRENGTH_STEP +export const STRENGTH_SNAP_EPSILON = STRENGTH_STEP / 2; +export function snapStrength(s: number): number { + if (!isFinite(s)) return 0; + if (s <= STRENGTH_SNAP_EPSILON) return 0; + if (s >= 1 - STRENGTH_SNAP_EPSILON) return 1; + const clamped = Math.min(Math.max(s, 0), 1); + return Math.round(clamped * STRENGTH_DENOM) / STRENGTH_DENOM; +} + +export function formatStrength(s: number): string { + // Display with up to 3 decimal places (matching the 0.025 step), dropping + // trailing zeros so 0.5 shows as "0.5", 0.025 shows as "0.025". + return Number(s.toFixed(3)).toString(); +} + +export function clampPoint(p: Point): Point { + const out: Point = { + x: Math.min(Math.max(Math.round(p.x), 0), 200), + y: Math.min(Math.max(Math.round(p.y), 0), 200), + }; + if (p.strength !== undefined && p.strength > 0) { + out.strength = snapStrength(p.strength); + } + return out; +} + +export function polygonCentroid(poly: Point[]): Point { + const n = poly.length; + let area = 0; + let cx = 0; + let cy = 0; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const cross = poly[i].x * poly[j].y - poly[j].x * poly[i].y; + area += cross; + cx += (poly[i].x + poly[j].x) * cross; + cy += (poly[i].y + poly[j].y) * cross; + } + area /= 2; + if (Math.abs(area) < 1e-6) { + // Degenerate — fall back to bounding box center. + const xs = poly.map((p) => p.x); + const ys = poly.map((p) => p.y); + return { + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + }; + } + cx /= 6 * area; + cy /= 6 * area; + return { x: cx, y: cy }; +} + +// On-curve midpoint of the edge from poly[i] to poly[(i+1) % N]. For a plain +// (zero-tension) edge this collapses to the linear midpoint because +// sampleSegment with tension=0 returns the linear interpolation; for a +// splined edge it samples the same Catmull-Rom curve the renderer draws, +// so the insert handle visually rides the rendered outline. +export function curveMidpoint(poly: Point[], i: number): Point { + const n = poly.length; + if (n < 2) return { x: poly[0]?.x ?? 0, y: poly[0]?.y ?? 0 }; + const iPrev = (i - 1 + n) % n; + const iNext = (i + 1) % n; + const iNext2 = (iNext + 1) % n; + const p0 = poly[iPrev]; + const p1 = poly[i]; + const p2 = poly[iNext]; + const p3 = poly[iNext2]; + const s1 = clamp01(p1.strength ?? 0); + const s2 = clamp01(p2.strength ?? 0); + const edgeTension = clamp01((s1 + s2) * 0.5); + return sampleSegment(p0, p1, p2, p3, 0.5, edgeTension); +} + +// Strength to give a vertex inserted on the edge from poly[i] to poly[i+1]. +// Average of the two endpoint strengths so adding a point in the middle of a +// smooth edge keeps the curve smooth (rather than introducing a sharp corner +// that would visually break the rendered shape). +export function insertionStrength(poly: Point[], i: number): number { + const s1 = poly[i].strength ?? 0; + const s2 = poly[(i + 1) % poly.length].strength ?? 0; + return snapStrength((s1 + s2) / 2); +} + +// Choose MUI Popover origins so the strength popover projects outward from +// the polygon — always toward the empty side of the vertex rather than over +// the rest of the shape. The centroid→vertex vector is the most reliable +// signal for "which way is outside" because it works for both convex and +// concave polygons (a concave vertex's chord-midpoint can sit on the wrong +// side of the boundary, but the centroid is always inside the bulk). +export type PopoverOrigin = { + vertical: "top" | "bottom"; + horizontal: "left" | "right"; +}; +export function popoverOriginsFor( + poly: Point[], + i: number +): { anchorOrigin: PopoverOrigin; transformOrigin: PopoverOrigin } { + const here = poly[i]; + const c = polygonCentroid(poly); + // Default to the bottom-right quadrant when the vertex coincides with the + // centroid (degenerate). dx >= 0 / dy >= 0 → right / bottom — i.e. popover + // extends down-right from the anchor, matching the prior fixed behavior. + const horizontal: "left" | "right" = here.x - c.x >= 0 ? "right" : "left"; + const vertical: "top" | "bottom" = here.y - c.y >= 0 ? "bottom" : "top"; + return { + anchorOrigin: { vertical, horizontal }, + transformOrigin: { + vertical: vertical === "bottom" ? "top" : "bottom", + horizontal: horizontal === "right" ? "left" : "right", + }, + }; +} + +// --------------------------------------------------------------------------- +// Catmull-Rom spline tessellation (1:1 port of bar-game/common/lib_spline.lua) +// --------------------------------------------------------------------------- + +export const TESSELLATION_SEGMENTS = 12; + +export function clamp01(v: number): number { + if (v < 0) return 0; + if (v > 1) return 1; + return v; +} + +// Centripetal knot spacing: |delta|^0.5 (alpha = 0.5). +export function knotDelta( + a: { x: number; y: number }, + b: { x: number; y: number } +): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + return Math.pow(dx * dx + dy * dy, 0.25); +} + +// Barry-Goldman lerp of a->b over knot span [ta, tb], evaluated at tt. +export function bgLerp( + tt: number, + a: { x: number; y: number }, + b: { x: number; y: number }, + ta: number, + tb: number +): { x: number; y: number } { + const w = (tb - tt) / (tb - ta); + return { x: w * a.x + (1 - w) * b.x, y: w * a.y + (1 - w) * b.y }; +} + +// Sample a centripetal Catmull-Rom curve segment between p1 and p2 (neighbours +// p0, p3), blended toward the straight chord by `tension` in [0, 1]. Centripetal +// (alpha = 0.5) avoids the curly-q overshoot/self-intersections at sharp corners. +export function sampleSegment( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + t: number, + tension: number +): Point { + const lx = p1.x + (p2.x - p1.x) * t; + const ly = p1.y + (p2.y - p1.y) * t; + if (tension <= 0) return { x: lx, y: ly }; + + const t0 = 0; + const t1 = t0 + knotDelta(p0, p1); + const t2 = t1 + knotDelta(p1, p2); + const t3 = t2 + knotDelta(p2, p3); + + let crX: number; + let crY: number; + if (t2 - t1 <= 1e-9) { + crX = p1.x; + crY = p1.y; + } else { + const tt = t1 + (t2 - t1) * t; + const A1 = + t1 - t0 > 1e-9 ? bgLerp(tt, p0, p1, t0, t1) : { x: p1.x, y: p1.y }; + const A2 = bgLerp(tt, p1, p2, t1, t2); + const A3 = + t3 - t2 > 1e-9 ? bgLerp(tt, p2, p3, t2, t3) : { x: p2.x, y: p2.y }; + const B1 = bgLerp(tt, A1, A2, t0, t2); + const B2 = bgLerp(tt, A2, A3, t1, t3); + const C = bgLerp(tt, B1, B2, t1, t2); + crX = C.x; + crY = C.y; + } + + if (tension >= 1) return { x: crX, y: crY }; + return { + x: lx + (crX - lx) * tension, + y: ly + (crY - ly) * tension, + }; +} + +// Tessellate a closed ring of anchor points into a dense polygon. Anchors +// without explicit strength are treated as sharp corners (strength 0); plain +// polygons emerge with vertex-identical output. +export function tessellateRing( + anchors: Point[], + segments = TESSELLATION_SEGMENTS +): Point[] { + const n = anchors.length; + if (n < 2) return anchors.map((p) => ({ x: p.x, y: p.y })); + const seg = Math.max(1, segments); + + const out: Point[] = []; + for (let i = 0; i < n; i++) { + const iPrev = (i - 1 + n) % n; + const iNext = (i + 1) % n; + const iNext2 = (iNext + 1) % n; + const p0 = anchors[iPrev]; + const p1 = anchors[i]; + const p2 = anchors[iNext]; + const p3 = anchors[iNext2]; + + const s1 = clamp01(p1.strength ?? 0); + const s2 = clamp01(p2.strength ?? 0); + const edgeTension = clamp01((s1 + s2) * 0.5); + + out.push({ x: p1.x, y: p1.y }); + if (edgeTension > 0 && n >= 3) { + for (let k = 1; k < seg; k++) { + out.push(sampleSegment(p0, p1, p2, p3, k / seg, edgeTension)); + } + } + } + return out; +} + +// SVG path for the (potentially curved) polygon outline. Plain polygons emit +// a tessellation of length N (the anchors themselves) and look identical to +// the previous straight-edged rendering. +export function tessellatedPathString(poly: Point[]): string { + const tess = tessellateRing(poly); + if (tess.length === 0) return ""; + const parts: string[] = [`M ${tess[0].x} ${tess[0].y}`]; + for (let i = 1; i < tess.length; i++) { + parts.push(`L ${tess[i].x} ${tess[i].y}`); + } + parts.push("Z"); + return parts.join(" "); +} + +// Color and radius for an anchor handle based on strength: a sharp corner +// (0) is a small, dim red dot; max smoothness (1) is a brighter, larger dot. +export function strengthVisuals(strength: number): { fill: string; r: number } { + const s = clamp01(strength); + // Hue ramps from red (0°) toward orange/yellow (~50°) as strength rises. + const hue = 0 + 50 * s; + const sat = 80; + const lit = 45 + 10 * s; + return { fill: `hsl(${hue}, ${sat}%, ${lit}%)`, r: 2.2 + 1.0 * s }; +} diff --git a/src/components/startbox/serialization.ts b/src/components/startbox/serialization.ts new file mode 100644 index 000000000..9b419d2a3 --- /dev/null +++ b/src/components/startbox/serialization.ts @@ -0,0 +1,72 @@ +import { + Point, + Startbox, + snapStrength, + formatStrength, + isLegacyRect, + rectToPolygon, + tryPolygonToRect, +} from "./geometry"; + +export function getStartboxString(poly: Point[]): string { + return poly + .map((p) => { + const s = p.strength ?? 0; + if (s <= 0) return `${p.x} ${p.y}`; + return `${p.x} ${p.y} ${formatStrength(s)}`; + }) + .join(", "); +} + +export function parseStartboxString(startboxString: string): Point[] { + const parts = startboxString + .trim() + .split(/,/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + const points: Point[] = []; + for (const part of parts) { + const tokens = part.split(/ +/); + if (tokens.length !== 2 && tokens.length !== 3) { + throw new Error(`expected 'x y' or 'x y strength', got '${part}'`); + } + + const x = parseInt(tokens[0], 10); + const y = parseInt(tokens[1], 10); + if (isNaN(x)) throw new Error(`'${tokens[0]}' is not a number`); + if (isNaN(y)) throw new Error(`'${tokens[1]}' is not a number`); + if (x < 0 || x > 200) throw new Error(`x=${x} not in range 0-200`); + if (y < 0 || y > 200) throw new Error(`y=${y} not in range 0-200`); + + const point: Point = { x, y }; + if (tokens.length === 3) { + const sRaw = parseFloat(tokens[2]); + if (isNaN(sRaw)) throw new Error(`'${tokens[2]}' is not a number`); + if (sRaw < 0 || sRaw > 1) + throw new Error(`strength=${sRaw} not in range 0-1`); + const s = snapStrength(sRaw); + if (s > 0) point.strength = s; + } + points.push(point); + } + + if (points.length < 3) { + throw new Error(`need at least 3 vertices, got ${points.length}`); + } + return points; +} + +// Convert stored startbox data to editor polygon state. +export function loadPoly(box: Startbox): Point[] { + if (isLegacyRect(box.poly)) { + return rectToPolygon(box.poly as [Point, Point]); + } + return box.poly; +} + +// Convert editor polygon state back to stored startbox data. +// Preserves 2-point rectangle format when possible (no strengths). +export function savePoly(poly: Point[]): Startbox { + return { poly: tryPolygonToRect(poly) }; +} diff --git a/src/components/startbox/state.ts b/src/components/startbox/state.ts new file mode 100644 index 000000000..0daa5ab64 --- /dev/null +++ b/src/components/startbox/state.ts @@ -0,0 +1,137 @@ +import { Point, clampPoint, pointEqual, snapStrength } from "./geometry"; +import { getStartboxString, parseStartboxString } from "./serialization"; + +// Immutable state object for single startbox +export class StartboxState { + public readonly str: string; + + constructor( + public readonly poly: Point[], + str?: string, + public readonly strErr?: string + ) { + if (str !== undefined) { + this.str = str; + } else { + this.str = getStartboxString(poly); + } + } + + setStr(str: string): StartboxState { + if (str === this.str) { + return this; + } + try { + return new StartboxState(parseStartboxString(str), str); + } catch (e) { + return new StartboxState(this.poly, str, (e as Error).message); + } + } + + setVertex(index: number, point: Point): StartboxState { + const clamped = clampPoint(point); + if (pointEqual(this.poly[index], clamped)) return this; + const newPoly = [...this.poly]; + // Preserve existing strength if not explicitly provided in `point` + if ( + point.strength === undefined && + this.poly[index].strength !== undefined + ) { + clamped.strength = this.poly[index].strength; + } + newPoly[index] = clamped; + return new StartboxState(newPoly); + } + + // Resize a rectangle by dragging one corner: the corner moves to `point` and + // the two adjacent corners track its shared edges, keeping the box axis-aligned. + // Callers gate on isRectangle; falls back to setVertex for non-4-point shapes. + resizeRectCorner(index: number, point: Point): StartboxState { + if (this.poly.length !== 4) return this.setVertex(index, point); + const clamped = clampPoint(point); + const corner = this.poly[index]; + const newPoly = this.poly.map((p, j) => { + if (j === index) return { x: clamped.x, y: clamped.y }; + return { + x: p.x === corner.x ? clamped.x : p.x, + y: p.y === corner.y ? clamped.y : p.y, + }; + }); + if (this.poly.every((p, i) => pointEqual(p, newPoly[i]))) return this; + return new StartboxState(newPoly); + } + + setVertexStrength(index: number, rawStrength: number): StartboxState { + const snapped = snapStrength(rawStrength); + const current = this.poly[index].strength ?? 0; + if (current === snapped) return this; + const newPoly = [...this.poly]; + const updated: Point = { x: this.poly[index].x, y: this.poly[index].y }; + if (snapped > 0) updated.strength = snapped; + newPoly[index] = updated; + return new StartboxState(newPoly); + } + + setUniformStrength(rawStrength: number): StartboxState { + const snapped = snapStrength(rawStrength); + const same = this.poly.every((p) => (p.strength ?? 0) === snapped); + if (same) return this; + const newPoly = this.poly.map((p) => { + const out: Point = { x: p.x, y: p.y }; + if (snapped > 0) out.strength = snapped; + return out; + }); + return new StartboxState(newPoly); + } + + insertVertex(afterIndex: number, point: Point): StartboxState { + const newPoly = [...this.poly]; + newPoly.splice(afterIndex + 1, 0, clampPoint(point)); + return new StartboxState(newPoly); + } + + removeVertex(index: number): StartboxState { + if (this.poly.length <= 3) return this; + const newPoly = [...this.poly]; + newPoly.splice(index, 1); + return new StartboxState(newPoly); + } + + moveBy(dx: number, dy: number): StartboxState { + const moved = this.poly.map((p) => { + const moved = clampPoint({ x: p.x + dx, y: p.y + dy }); + if (p.strength !== undefined) moved.strength = p.strength; + return moved; + }); + if (this.poly.every((p, i) => pointEqual(p, moved[i]))) return this; + return new StartboxState(moved); + } +} + +// Immutable state object for all startboxes +export class StartboxesState { + constructor(public readonly boxes: StartboxState[]) {} + + update( + idx: number, + update: (box: StartboxState) => StartboxState + ): StartboxesState { + const newBox = update(this.boxes[idx]); + if (newBox === this.boxes[idx]) { + return this; + } + const newStartboxes = [...this.boxes]; + newStartboxes[idx] = newBox; + return new StartboxesState(newStartboxes); + } + + add(poly: Point[]): StartboxesState { + return new StartboxesState([...this.boxes, new StartboxState(poly)]); + } + + remove(idx: number): StartboxesState { + const newStartboxes = [...this.boxes]; + newStartboxes.splice(idx, 1); + return new StartboxesState(newStartboxes); + } +} diff --git a/src/components/startbox/useStartboxDraw.ts b/src/components/startbox/useStartboxDraw.ts new file mode 100644 index 000000000..83ba73b3b --- /dev/null +++ b/src/components/startbox/useStartboxDraw.ts @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { Point } from "./geometry"; + +export type DrawMode = "rect" | "polygon"; + +interface DrawPreview { + points: Point[]; + closed: boolean; +} + +function clampInt(v: number): number { + return Math.min(Math.max(Math.round(v), 0), 200); +} + +function clampPointInt(p: Point): Point { + return { x: clampInt(p.x), y: clampInt(p.y) }; +} + +function rectCorners(a: Point, b: Point): Point[] { + const minX = Math.min(a.x, b.x); + const maxX = Math.max(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxY = Math.max(a.y, b.y); + return [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]; +} + +const CLOSE_DISTANCE = 6; + +export function useStartboxDraw(onComplete: (poly: Point[]) => void) { + const [mode, setMode] = useState(null); + const [rectStart, setRectStart] = useState(null); + const [cursor, setCursor] = useState(null); + const [polyPoints, setPolyPoints] = useState([]); + + function reset() { + setMode(null); + setRectStart(null); + setCursor(null); + setPolyPoints([]); + } + + function start(next: DrawMode) { + setRectStart(null); + setCursor(null); + setPolyPoints([]); + setMode(next); + } + + function cancel() { + reset(); + } + + function commit() { + if (mode !== "polygon") return; + if (polyPoints.length >= 3) { + const poly = polyPoints; + reset(); + onComplete(poly); + } + } + + function onMouseDown(raw: Point) { + const p = clampPointInt(raw); + + if (mode === "rect") { + setRectStart(p); + setCursor(p); + + return; + } + + if (mode === "polygon") { + if (polyPoints.length >= 3) { + const first = polyPoints[0]; + const dx = p.x - first.x; + const dy = p.y - first.y; + if (dx * dx + dy * dy <= CLOSE_DISTANCE * CLOSE_DISTANCE) { + const poly = polyPoints; + reset(); + onComplete(poly); + + return; + } + } + + setPolyPoints([...polyPoints, p]); + } + } + + function onMouseMove(raw: Point) { + if (mode === null) return; + + setCursor(clampPointInt(raw)); + } + + function onMouseUp(raw: Point) { + const p = clampPointInt(raw); + + if (mode !== "rect" || rectStart === null) return; + + const w = Math.abs(p.x - rectStart.x); + const h = Math.abs(p.y - rectStart.y); + if (w >= 2 && h >= 2) { + const poly = rectCorners(rectStart, p); + reset(); + onComplete(poly); + + return; + } + + setRectStart(null); + } + + let preview: DrawPreview | null = null; + if (mode === "rect" && rectStart !== null && cursor !== null) { + preview = { points: rectCorners(rectStart, cursor), closed: true }; + } else if (mode === "polygon" && polyPoints.length >= 1) { + preview = { + points: cursor !== null ? [...polyPoints, cursor] : [...polyPoints], + closed: false, + }; + } + + return { + mode, + preview, + start, + cancel, + commit, + onMouseDown, + onMouseMove, + onMouseUp, + }; +}