diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index 1d62ad3..35a1478 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -2,6 +2,7 @@ export { default as Input } from './Input.svelte'; export { default as InputCheckbox } from './inputs/InputCheckbox.svelte'; export { default as InputNumber } from './inputs/InputNumber.svelte'; export { default as InputSelect } from './inputs/InputSelect.svelte'; +export { default as InputShape } from './inputs/InputShape.svelte'; export { default as InputVec3 } from './inputs/InputVec3.svelte'; export { default as Details } from './Details.svelte'; diff --git a/packages/ui/src/lib/inputs/InputCheckbox.svelte b/packages/ui/src/lib/inputs/InputCheckbox.svelte index 1435b91..1d2e75a 100644 --- a/packages/ui/src/lib/inputs/InputCheckbox.svelte +++ b/packages/ui/src/lib/inputs/InputCheckbox.svelte @@ -27,7 +27,7 @@ {id} /> + type Vec2 = [ + number, + number + ]; + + let mouseDown = $state(); + let activeCircle = $state(); + let downCirclePosition = $state(); + let svgElement = $state(null!); + let svgRect = $state(null!); + + type Props = { + points: Vec2[]; + mirror?: boolean; + }; + + function defaultPoints(): Vec2[] { + return [ + [0, 0], + [10, 10] + ]; + } + + let { points = $bindable(), mirror = true }: Props = $props(); + + $effect(() => { + if (!points.length) { + points = defaultPoints(); + } + }); + + function mirrorPoints(pts: Vec2[]) { + const _pts: Vec2[] = []; + + for (const pt of pts) { + _pts.push([ + 100 - pt[0], + pt[1] + ]); + } + + return _pts; + } + + $effect(() => { + const pts = $state.snapshot(points) + .map((p, i) => [...p, i]) + .sort((a, b) => { + if (a[1] !== b[1]) return b[1] - a[1]; + return a[0] - b[0]; + }) + .map((p, i) => [...p, i]); + + let diff = pts.find((p, i) => p[0] !== points[i][0] || p[1] !== points[i][1]); + if (diff) { + points = pts.map(p => [p[0], p[1]]); + const newActiveCircle = pts.find(p => p[2] === activeCircle); + if (newActiveCircle) { + activeCircle = newActiveCircle[3]; + } + } + }); + + function calculatePath(points: Vec2[], mirror = false): string { + if (points.length === 0) return ''; + + const pts = $state.snapshot(points); + if (mirror) { + pts.sort((a, b) => (a[1] > b[1] ? -1 : 1)); + } + + let d = `M ${pts[0][0]} ${pts[0][1]}`; + + for (let i = 1; i < pts.length; i++) { + d += ` L ${pts[i][0]} ${pts[i][1]}`; + } + + if (mirror) { + for (let i = pts.length - 1; i >= 0; i--) { + const p = pts[i]; + const x = mirror ? 100 - p[0] : p[0]; + d += ` L ${x} ${p[1]}`; + } + d += ' Z'; + } + + return d; + } + + const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); + const round = (v: number) => Math.floor(v * 10) / 10; + + function handleMouseMove(ev: MouseEvent) { + if (mouseDown === undefined || activeCircle === undefined || !downCirclePosition) return; + + let vx = (mouseDown[0] - ev.clientX) * (100 / svgRect.width); + let vy = (mouseDown[1] - ev.clientY) * (100 / svgRect.height); + + let x = downCirclePosition[0] - vx; + let y = downCirclePosition[1] - vy; + + x = clamp(x, 0, mirror ? 50 : 100); + y = clamp(y, 0, 100); + + points[activeCircle][0] = round(x); + points[activeCircle][1] = round(y); + } + + function clientToSvg(ev: MouseEvent): Vec2 { + svgRect = svgElement.getBoundingClientRect(); + + const x = ((ev.clientX - svgRect.left) / svgRect.width) * 100; + const y = ((ev.clientY - svgRect.top) / svgRect.height) * 100; + + return [ + round(clamp(x, 0, mirror ? 50 : 100)), + round(clamp(y, 0, 100)) + ]; + } + + function dist(a: Vec2, b: Vec2) { + return Math.hypot(a[0] - b[0], a[1] - b[1]); + } + + function insertBetween(newPt: Vec2) { + if (points.length < 2) { + points = [...points, newPt]; + return; + } + + let minDist = Infinity; + let insertIdx = 0; + + for (let i = 0; i < points.length - 1; i++) { + const d = dist(newPt, points[i]) + dist(newPt, points[i + 1]) + - dist(points[i], points[i + 1]); + if (d < minDist) { + minDist = d; + insertIdx = i + 1; + } + } + + points.splice(insertIdx, 0, newPt); + } + + function handleMouseDown(ev: MouseEvent) { + ev.preventDefault(); + + svgRect = svgElement.getBoundingClientRect(); + mouseDown = [ev.clientX, ev.clientY]; + + const target = ev.target as SVGCircleElement; + const index = target.dataset?.index; + + if (index !== undefined) { + activeCircle = parseInt(index); + downCirclePosition = [...points[activeCircle]]; + } else { + const pt = clientToSvg(ev); + if (mirror && pt[0] > 50) return; + insertBetween(pt); + activeCircle = points.findIndex(p => p[0] === pt[0] && p[1] === pt[1]); + downCirclePosition = [...pt]; + } + } + + function handleMouseUp() { + mouseDown = undefined; + } + + function handleKeyDown(ev: KeyboardEvent) { + if (activeCircle === undefined) return; + if (ev.key === 'Delete' || ev.key === 'Backspace') { + const pts = [...points]; + pts.splice(activeCircle, 1); + points = pts; + activeCircle = undefined; + } + } + + + + +
+ + + + + {#if mirror} + {#each mirrorPoints(points) as point, i (i + '-' + point[0] + '-' + point[1])} + + + {/each} + {/if} + + {#each points as point, i (i + '-' + point[0] + '-' + point[1])} + + + {/each} + +
+ + diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index bb69ecd..6a04005 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -1,6 +1,14 @@