From 2b2b88921d5c3a076dd89d76d33135ddc72e33df Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Sat, 28 Feb 2026 16:56:03 +0100 Subject: [PATCH] feat(pixel-draw-renderer): initial UVs implementation --- .../pixel-draw-renderer/docs/llms/PROMPT.md | 110 +++++ .../pixel-draw-renderer/examples/index.html | 23 +- .../examples/public/main.css | 106 +++++ .../examples/scripts/components/Camera.ts | 2 +- .../examples/scripts/components/CubeWithUV.ts | 72 +++ .../examples/scripts/components/UVPanel.ts | 105 +++++ .../examples/scripts/main.ts | 163 ++++++- .../pixel-draw-renderer/src/CanvasManager.ts | 73 ++- .../src/InputController.ts | 34 ++ .../pixel-draw-renderer/src/SvgManager.ts | 4 + packages/pixel-draw-renderer/src/UVHistory.ts | 98 ++++ .../pixel-draw-renderer/src/UVInputHandler.ts | 378 ++++++++++++++++ packages/pixel-draw-renderer/src/UVMap.ts | 421 ++++++++++++++++++ packages/pixel-draw-renderer/src/UVRegion.ts | 77 ++++ .../pixel-draw-renderer/src/UVRenderer.ts | 376 ++++++++++++++++ packages/pixel-draw-renderer/src/index.ts | 25 +- packages/pixel-draw-renderer/src/types.ts | 21 +- .../test/UVHistory.spec.ts | 212 +++++++++ .../test/UVInputHandler.spec.ts | 242 ++++++++++ .../pixel-draw-renderer/test/UVMap.spec.ts | 266 +++++++++++ .../pixel-draw-renderer/test/UVRegion.spec.ts | 132 ++++++ .../test/UVRenderer.spec.ts | 183 ++++++++ 22 files changed, 3102 insertions(+), 21 deletions(-) create mode 100644 packages/pixel-draw-renderer/docs/llms/PROMPT.md create mode 100644 packages/pixel-draw-renderer/examples/scripts/components/CubeWithUV.ts create mode 100644 packages/pixel-draw-renderer/examples/scripts/components/UVPanel.ts create mode 100644 packages/pixel-draw-renderer/src/UVHistory.ts create mode 100644 packages/pixel-draw-renderer/src/UVInputHandler.ts create mode 100644 packages/pixel-draw-renderer/src/UVMap.ts create mode 100644 packages/pixel-draw-renderer/src/UVRegion.ts create mode 100644 packages/pixel-draw-renderer/src/UVRenderer.ts create mode 100644 packages/pixel-draw-renderer/test/UVHistory.spec.ts create mode 100644 packages/pixel-draw-renderer/test/UVInputHandler.spec.ts create mode 100644 packages/pixel-draw-renderer/test/UVMap.spec.ts create mode 100644 packages/pixel-draw-renderer/test/UVRegion.spec.ts create mode 100644 packages/pixel-draw-renderer/test/UVRenderer.spec.ts diff --git a/packages/pixel-draw-renderer/docs/llms/PROMPT.md b/packages/pixel-draw-renderer/docs/llms/PROMPT.md new file mode 100644 index 0000000..dd261c9 --- /dev/null +++ b/packages/pixel-draw-renderer/docs/llms/PROMPT.md @@ -0,0 +1,110 @@ +# Task: Implement UV Mapping System in `pixel-draw-renderer` + +## Overview + +I want you to create a detailed implementation plan for a full UV mapping system +within the `pixel-draw-renderer` workspace. The goal is to allow users to define, +manage, and edit UV islands on a texture atlas, with real-time updates reflected +on Three.js geometries — similar to how faces are textured in the `voxel-renderer` +workspace (see `src/blocks/shapes/**.ts`). + +--- + +## Context & Architecture + +Before planning, please explore and understand the current codebase: + +- **`pixel-draw-renderer`**: The main workspace where UV editing will live. + Understand its current rendering pipeline, canvas management, tools/modes system, + and how it handles user interaction. +- **`voxel-renderer`** (`src/blocks/shapes/**.ts`): Understand how geometry faces + are currently UV-mapped (hardcoded or otherwise), and how the texture is consumed + by Three.js materials. This is the downstream consumer we need to feed into. +- **Blockbench reference** (`uv.js`): + https://github.com/JannisX11/blockbench/blob/master/js/texturing/uv.js + Use this as inspiration for UV data modeling, face-UV assignment patterns, + multi-UV management, and interaction paradigms (not for direct copy-paste). + +--- + +## Features to Plan & Implement + +### 1. UV Data Model +- Define a `UV` class or interface: position (`u`, `v`), size (`width`, `height`), + rotation, and an associated face ID or label. +- Support multiple UVs per texture (UV islands / atlas regions). +- UVs should be serializable (JSON) so they can be saved/restored with the project. +- Consider a `UVMap` registry that holds all UVs for a given texture. + +### 2. UV Editing Mode +- Introduce a new distinct **UV Edit Mode** (separate from draw/erase/select modes) + that can be toggled from the toolbar or via a keyboard shortcut. +- In UV Edit Mode, the canvas overlays UV regions as colored, semi-transparent + rectangles with handles. +- The user should be able to: + - **Add** a new UV island (click + drag to define bounds). + - **Select** an existing UV (click to highlight). + - **Move** a UV (drag selected UV across the texture surface). + - **Resize** a UV (drag corner/edge handles). + - **Delete** a UV (keyboard shortcut or context menu). + - **Label/rename** a UV (to match face IDs from voxel-renderer). + +### 3. UV Stacking & Face Management +- Allow multiple UVs to be **stacked** (overlapping, sharing the same texture region), + modeling the concept of multiple geometry faces sharing the same UV space. +- Provide a **stack/unstack** toggle per UV island. +- Display a face panel (sidebar or floating panel) listing all defined UVs/faces, + inspired by how Blockbench lists cube faces. Each entry should show: + - Face label (e.g. `top`, `bottom`, `north`, `south`, `east`, `west`) + - UV coordinates + - A visibility toggle + - A "jump to UV" button + +### 4. Real-Time Three.js Texture Sync +- When a UV is moved or resized, recompute the UV coordinates for the associated + face and update the Three.js `BufferGeometry` attribute (`uv`) in real time. +- Ensure the Three.js material texture is flagged `needsUpdate = true` after + pixel edits that affect a UV region. +- Explore whether this sync should be event-driven (emitting a `uv:changed` event) + or reactive (MobX/signals/store-based), consistent with the existing architecture. + +### 5. Snapping & Grid Alignment +- UVs should snap to the pixel grid by default (configurable). +- Optional snap-to-other-UVs behavior (edge alignment). + +### 6. Undo/Redo Support +- All UV operations (add, move, resize, delete) must integrate with the existing + undo/redo history stack. + +### 7. Update the example +- Update the demo in `./examples` so we are able to test everything visually with Three.js + +--- + +## Deliverables Expected in the Plan + +1. **File & folder structure** — where new UV-related classes, stores, and components + should live within the existing project layout. +2. **Class/interface definitions** — outline the key data structures (`UV`, `UVMap`, + `UVEditTool`, etc.) with their properties and methods. +3. **Mode system integration** — how to hook UV Edit Mode into the existing + tool/mode architecture without breaking current modes. +4. **Rendering pipeline** — how UV overlays are drawn on the canvas (separate + overlay canvas, SVG layer, or direct canvas compositing?). +5. **Three.js sync strategy** — the exact mechanism and touch points for keeping + geometry UVs in sync with edits. +6. **Migration path** — how to convert any existing hardcoded UVs in `voxel-renderer` + shapes into the new system without breaking current behavior. +7. **Open questions** — flag any architectural ambiguities that need a decision + before implementation begins. + +--- + +## Constraints & Preferences + +- Stay consistent with the existing code style, patterns, and abstractions already + present in the workspace. +- Prefer incremental, non-breaking changes — the voxel-renderer should keep working + throughout the migration. +- Performance matters: UV overlay rendering should not degrade canvas frame rate + during pixel drawing. diff --git a/packages/pixel-draw-renderer/examples/index.html b/packages/pixel-draw-renderer/examples/index.html index 16d1c6d..0339afc 100644 --- a/packages/pixel-draw-renderer/examples/index.html +++ b/packages/pixel-draw-renderer/examples/index.html @@ -14,19 +14,28 @@
+
-
- +
+
+ +
+
-
+
diff --git a/packages/pixel-draw-renderer/examples/public/main.css b/packages/pixel-draw-renderer/examples/public/main.css index 17df5c1..1739c71 100644 --- a/packages/pixel-draw-renderer/examples/public/main.css +++ b/packages/pixel-draw-renderer/examples/public/main.css @@ -71,6 +71,12 @@ html.handle-dragging.vertical * { user-select: none; } +.toolbar-item-group { + display: flex; + align-items: center; + gap: 6px; +} + .toolbar-item { display: flex; align-items: center; @@ -123,3 +129,103 @@ html.handle-dragging.vertical * { font-size: 11px; color: #ccc; } + +/* ─── UV Panel ──────────────────────────────────────────────────────────── */ + +#uv-panel { + display: none; + flex-direction: column; + position: absolute; + top: 8px; + right: 8px; + width: 160px; + background: rgba(20, 28, 36, 0.88); + border: 1px solid #334; + border-radius: 6px; + overflow: hidden; + font-family: sans-serif; + font-size: 12px; + color: #ccc; + z-index: 10; + backdrop-filter: blur(4px); +} + +#uv-panel.visible { + display: flex; +} + +.uv-panel-header { + padding: 6px 10px; + font-size: 11px; + font-weight: 600; + color: #aab; + background: rgba(0, 0, 0, 0.3); + letter-spacing: 0.04em; + text-transform: uppercase; + border-bottom: 1px solid #334; +} + +#uv-face-list { + display: flex; + flex-direction: column; + overflow-y: auto; + max-height: 220px; +} + +.uv-face-item { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + cursor: pointer; + transition: background 0.1s; +} + +.uv-face-item:hover { + background: rgba(255, 255, 255, 0.07); +} + +.uv-face-item.active { + background: rgba(68, 136, 255, 0.2); + color: #fff; +} + +.uv-face-color { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.uv-face-name { + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#uv-coords { + display: none; + grid-template-columns: auto 1fr; + gap: 3px 8px; + padding: 6px 10px; + border-top: 1px solid #334; + font-size: 11px; + color: #aab; +} + +.uv-coord-label { + color: #668; + font-weight: 600; +} + +.uv-hint { + margin: 0; + padding: 6px 10px; + font-size: 10px; + color: #556; + border-top: 1px solid #334; + text-align: center; +} diff --git a/packages/pixel-draw-renderer/examples/scripts/components/Camera.ts b/packages/pixel-draw-renderer/examples/scripts/components/Camera.ts index 5ea227f..b719fd1 100644 --- a/packages/pixel-draw-renderer/examples/scripts/components/Camera.ts +++ b/packages/pixel-draw-renderer/examples/scripts/components/Camera.ts @@ -16,7 +16,7 @@ export class CameraBehavior extends CameraComponent { far: 100 }); - this.threeCamera.position.set(0, 0, 8); + this.threeCamera.position.set(0, 0, 10); } get camera(): THREE.PerspectiveCamera { diff --git a/packages/pixel-draw-renderer/examples/scripts/components/CubeWithUV.ts b/packages/pixel-draw-renderer/examples/scripts/components/CubeWithUV.ts new file mode 100644 index 0000000..4c3b6c8 --- /dev/null +++ b/packages/pixel-draw-renderer/examples/scripts/components/CubeWithUV.ts @@ -0,0 +1,72 @@ +// Import Third-party Dependencies +import { + type Actor, + ActorComponent +} from "@jolly-pixel/engine"; +import * as THREE from "three"; + +export interface CubeWithUVOptions { + canvasTexture: THREE.CanvasTexture; + /** World-space position for this cube. */ + position: { x: number; y: number; z: number; }; + /** Rotation speed multiplier (default 1). */ + speed?: number; + /** Which axes to rotate on. */ + rotateAxes?: { x?: boolean; y?: boolean; z?: boolean; }; +} + +/** + * A rotating cube whose UV attributes are updated externally. + * Exposes `geometry` so the caller can rebuild UVs on demand. + */ +export class CubeWithUV extends ActorComponent { + readonly geometry: THREE.BoxGeometry; + readonly mesh: THREE.Mesh; + + #speed: number; + #axes: { x: boolean; y: boolean; z: boolean; }; + #canvasTexture: THREE.CanvasTexture; + + constructor( + actor: Actor, + options: CubeWithUVOptions + ) { + super({ actor, typeName: "CubeWithUV" }); + + this.#canvasTexture = options.canvasTexture; + this.#speed = options.speed ?? 1; + this.#axes = { + x: options.rotateAxes?.x ?? false, + y: options.rotateAxes?.y ?? true, + z: options.rotateAxes?.z ?? false + }; + + this.geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5); + + this.mesh = new THREE.Mesh( + this.geometry, + new THREE.MeshStandardMaterial({ + map: this.#canvasTexture, + transparent: true + }) + ); + + this.mesh.position.set(options.position.x, options.position.y, options.position.z); + + this.actor.addChildren(this.mesh); + } + + update(): void { + const dt = 0.008 * this.#speed; + if (this.#axes.x) { + this.mesh.rotation.x += dt * 0.7; + } + if (this.#axes.y) { + this.mesh.rotation.y += dt; + } + if (this.#axes.z) { + this.mesh.rotation.z += dt * 0.5; + } + this.#canvasTexture.needsUpdate = true; + } +} diff --git a/packages/pixel-draw-renderer/examples/scripts/components/UVPanel.ts b/packages/pixel-draw-renderer/examples/scripts/components/UVPanel.ts new file mode 100644 index 0000000..6fc6b1d --- /dev/null +++ b/packages/pixel-draw-renderer/examples/scripts/components/UVPanel.ts @@ -0,0 +1,105 @@ +// Import Internal Dependencies +import type { UVMap, UVMapChangedDetail } from "../../../src/UVMap.ts"; + +export interface UVPanelOptions { + listEl: HTMLElement; + coordsEl: HTMLElement; + uvMap: UVMap; + /** Called when the user clicks a face in the list (before select is applied). */ + onFaceClick?: (id: string) => void; +} + +/** + * Manages the UV face-list sidebar: renders the region list, highlights the + * selected region, and shows live u/v/w/h coordinates for the selection. + */ +export class UVPanel { + #listEl: HTMLElement; + #coordsEl: HTMLElement; + #uvMap: UVMap; + #onFaceClick: ((id: string) => void) | undefined; + #onChanged: (event: Event) => void; + + constructor(options: UVPanelOptions) { + this.#listEl = options.listEl; + this.#coordsEl = options.coordsEl; + this.#uvMap = options.uvMap; + this.#onFaceClick = options.onFaceClick; + + this.#onChanged = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail.type === "add" || detail.type === "remove") { + this.#buildList(); + } + this.#refreshSelection(); + }; + + this.#uvMap.addEventListener("changed", this.#onChanged); + this.#buildList(); + this.#refreshSelection(); + } + + destroy(): void { + this.#uvMap.removeEventListener("changed", this.#onChanged); + } + + #buildList(): void { + this.#listEl.innerHTML = ""; + + for (const region of this.#uvMap) { + const item = document.createElement("div"); + item.className = "uv-face-item"; + item.dataset.id = region.id; + + const chip = document.createElement("span"); + chip.className = "uv-face-color"; + chip.style.background = region.color; + item.appendChild(chip); + + const nameEl = document.createElement("span"); + nameEl.className = "uv-face-name"; + nameEl.textContent = region.label; + item.appendChild(nameEl); + + item.addEventListener("click", () => { + this.#onFaceClick?.(region.id); + this.#uvMap.select(region.id); + }); + + this.#listEl.appendChild(item); + } + } + + #refreshSelection(): void { + const selectedId = this.#uvMap.selectedId; + + for (const item of this.#listEl.querySelectorAll(".uv-face-item")) { + const isSelected = item.dataset.id === selectedId; + item.classList.toggle("active", isSelected); + + // Refresh label (in case it was renamed) + const region = this.#uvMap.get(item.dataset.id ?? ""); + if (region) { + const nameEl = item.querySelector(".uv-face-name"); + if (nameEl) { + nameEl.textContent = region.label; + } + } + } + + // Update coordinate display + const region = selectedId ? this.#uvMap.get(selectedId) : null; + if (region) { + this.#coordsEl.style.display = "grid"; + this.#coordsEl.innerHTML = + `u${region.u.toFixed(3)}` + + `v${region.v.toFixed(3)}` + + `w${region.width.toFixed(3)}` + + `h${region.height.toFixed(3)}`; + } + else { + this.#coordsEl.style.display = "none"; + this.#coordsEl.innerHTML = ""; + } + } +} diff --git a/packages/pixel-draw-renderer/examples/scripts/main.ts b/packages/pixel-draw-renderer/examples/scripts/main.ts index a079618..2ff224f 100644 --- a/packages/pixel-draw-renderer/examples/scripts/main.ts +++ b/packages/pixel-draw-renderer/examples/scripts/main.ts @@ -5,15 +5,96 @@ import { ResizeHandle } from "@jolly-pixel/resize-handle"; import Picker from "vanilla-picker"; // Import Internal Dependencies -import { CanvasManager } from "../../src/index.ts"; +import { + CanvasManager, + UVMap, + type UVMapChangedDetail +} from "../../src/index.ts"; import { CameraBehavior } from "./components/Camera.ts"; -import { CubeBehavior } from "./components/Cube.ts"; +import { CubeWithUV } from "./components/CubeWithUV.ts"; +import { UVPanel } from "./components/UVPanel.ts"; + +// CONSTANTS +// Texture atlas: 3 cols × 2 rows → each face region is 1/3 wide × 0.5 tall +const kTexW = 24; +const kTexH = 16; +// BoxGeometry face order: +x, -x, +y, -y, +z, -z +const kFaceOrder = ["right", "left", "top", "bottom", "front", "back"] as const; const runtime = initRuntime(); loadRuntime(runtime, { focusCanvas: false }).catch(console.error); +function createUVMap(): UVMap { + const uvMap = new UVMap(); + const w = 1 / 3; + const h = 0.5; + + uvMap.add({ + id: "right", + label: "Right", + u: 0, v: 0, width: w, height: h, color: "#f44" + }); + uvMap.add({ + id: "left", + label: "Left", + u: w, v: 0, width: w, height: h, color: "#4af" + }); + uvMap.add({ + id: "top", + label: "Top", + u: 2 * w, v: 0, width: w, height: h, color: "#4f4" + }); + uvMap.add({ + id: "bottom", + label: "Bottom", + u: 0, v: h, width: w, height: h, color: "#fa4" + }); + uvMap.add({ + id: "front", + label: "Front", + u: w, v: h, width: w, height: h, color: "#a4f" + }); + uvMap.add({ + id: "back", + label: "Back", + u: 2 * w, v: h, width: w, height: h, color: "#4ff" + }); + + return uvMap; +} + +function rebuildUVs( + geometries: THREE.BoxGeometry[], + uvMap: UVMap +): void { + for (const geometry of geometries) { + const uvAttr = geometry.attributes.uv; + + kFaceOrder.forEach((id, faceIndex) => { + const region = uvMap.get(id); + const u = region?.u ?? 0; + const v = region?.v ?? 0; + const width = region?.width ?? 1; + const height = region?.height ?? 1; + + // Canvas UV origin is top-left; Three.js UV origin is bottom-left → flip V + const base = faceIndex * 4; + // top-left + uvAttr.setXY(base + 0, u, 1 - v); + // top-right + uvAttr.setXY(base + 1, u + width, 1 - v); + // bottom-left + uvAttr.setXY(base + 2, u, 1 - v - height); + // bottom-right + uvAttr.setXY(base + 3, u + width, 1 - v - height); + }); + + uvAttr.needsUpdate = true; + } +} + function initRuntime(): Runtime { const canvas = document.querySelector( "#canvas-container > canvas" @@ -36,34 +117,51 @@ function initRuntime(): Runtime { ); const drawPanel = document.getElementById("draw-panel") as HTMLDivElement; + + const uvMap = createUVMap(); + const canvasManager = new CanvasManager(drawPanel, { texture: { - size: { x: 16, y: 16 } + size: { x: kTexW, y: kTexH } }, defaultMode: "paint", zoom: { - default: 16, + default: 12, min: 1, max: 32, sensitivity: 0.6 }, - brush: { size: 8 } + brush: { size: 8 }, + uv: { + map: uvMap + } }); + // canvasManager.setTexture(preloadTexture(uvMap)); + + // === Mode buttons === const modePaintBtn = document.getElementById("mode-paint") as HTMLButtonElement; const modeMoveBtn = document.getElementById("mode-move") as HTMLButtonElement; + const modeUVBtn = document.getElementById("mode-uv") as HTMLButtonElement; + const paintControls = document.getElementById("paint-controls") as HTMLDivElement; + const uvPanelEl = document.getElementById("uv-panel") as HTMLElement; - function setMode(mode: "paint" | "move"): void { + function setMode(mode: "paint" | "move" | "uv"): void { canvasManager.setMode(mode); modePaintBtn.classList.toggle("active", mode === "paint"); modeMoveBtn.classList.toggle("active", mode === "move"); + modeUVBtn.classList.toggle("active", mode === "uv"); + const inUV = mode === "uv"; + uvPanelEl.classList.toggle("visible", inUV); + paintControls.style.display = inUV ? "none" : ""; } // Sync initial state with whatever defaultMode was passed to CanvasManager - setMode(canvasManager.getMode()); + setMode(canvasManager.getMode() as "paint" | "move" | "uv"); modePaintBtn.addEventListener("click", () => setMode("paint")); modeMoveBtn.addEventListener("click", () => setMode("move")); + modeUVBtn.addEventListener("click", () => setMode("uv")); // === Color picker === const colorSwatch = document.getElementById("color-swatch") as HTMLButtonElement; @@ -103,6 +201,7 @@ function initRuntime(): Runtime { brushSizeDisplay.textContent = `${size}px`; }); + // === Three.js canvas texture === const canvasTexture = new THREE.CanvasTexture(canvasManager.getTextureCanvas()); canvasTexture.magFilter = THREE.NearestFilter; canvasTexture.minFilter = THREE.NearestFilter; @@ -110,9 +209,55 @@ function initRuntime(): Runtime { world.createActor("camera") .addComponent(CameraBehavior); - world.createActor("cube") - .addComponent(CubeBehavior, { canvasTexture }); + // === Three cubes at different positions with distinct rotation styles === + const cubePositions = [ + { x: -2.5, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, + { x: 2.5, y: 0, z: 0 } + ]; + + const cubeComponents = cubePositions.map((position, i) => { + let rotateAxes; + if (i === 0) { + rotateAxes = { y: true }; + } + else if (i === 1) { + rotateAxes = { y: true, x: true }; + } + else { + rotateAxes = { y: true, z: true }; + } + + return world.createActor(`cube-${i}`) + .addComponentAndGet(CubeWithUV, { + canvasTexture, + position, + speed: 0.7 + i * 0.3, + rotateAxes + }); + }); + + const geometries = cubeComponents.map((c) => c.geometry); + + // Initial UV sync + rebuildUVs(geometries, uvMap); + + // Keep all cube UVs in sync whenever a region is moved/resized/added/removed + uvMap.addEventListener("changed", (e) => { + const { type } = (e as CustomEvent).detail; + if (type !== "select") { + rebuildUVs(geometries, uvMap); + } + }); + + // === UV face list panel === + new UVPanel({ + listEl: document.getElementById("uv-face-list") as HTMLElement, + coordsEl: document.getElementById("uv-coords") as HTMLElement, + uvMap + }); + // === Resize handling === world.renderer.on("resize", () => { canvasManager.onResize(); }); diff --git a/packages/pixel-draw-renderer/src/CanvasManager.ts b/packages/pixel-draw-renderer/src/CanvasManager.ts index 735ff00..510c40c 100644 --- a/packages/pixel-draw-renderer/src/CanvasManager.ts +++ b/packages/pixel-draw-renderer/src/CanvasManager.ts @@ -18,6 +18,14 @@ import { import { Viewport } from "./Viewport.ts"; +import { + UVRenderer +} from "./UVRenderer.ts"; +import { + UVInputHandler, + type UVSnappingOptions +} from "./UVInputHandler.ts"; +import type { UVMap } from "./UVMap.ts"; import type { Brush, DefaultViewport, @@ -59,6 +67,16 @@ export interface CanvasManagerOptions { * Use this hook to synchronize the edited texture with an external consumer. */ onDrawEnd?: () => void; + /** + * UV editing options. When provided, a UVRenderer and UVInputHandler are + * created and wired to the canvas. The `uvMap` is externally owned — the + * consumer subscribes to its "changed" events for Three.js sync. + */ + uv?: { + map: UVMap; + snapping?: Partial; + onRegionSelected?: (id: string | null) => void; + }; } export class CanvasManager { @@ -68,6 +86,8 @@ export class CanvasManager { #renderer: CanvasRenderer; #input: InputController; #svgManager: SvgManager; + #uvRenderer: UVRenderer | null = null; + #uvInputHandler: UVInputHandler | null = null; readonly brush: BrushManager; readonly viewport: DefaultViewport; @@ -141,6 +161,24 @@ export class CanvasManager { textureSize }); + if (options.uv) { + this.#uvRenderer = new UVRenderer({ + svg: this.#svgManager.getSvgElement(), + uvMap: options.uv.map, + viewport: viewportRef, + textureSize + }); + + this.#uvInputHandler = new UVInputHandler({ + viewport: this.#viewport, + uvMap: options.uv.map, + uvRenderer: this.#uvRenderer, + textureSize, + snapping: options.uv.snapping, + onRegionSelected: options.uv.onRegionSelected + }); + } + this.#input = new InputController({ canvas: this.#renderer.getCanvas(), viewport: this.#viewport, @@ -162,6 +200,7 @@ export class CanvasManager { onPanMove: (dx, dy) => { this.#viewport.applyPan(dx, dy); this.#renderer.drawFrame(); + this.#uvRenderer?.update(); }, onPanEnd: () => { // nothing extra needed @@ -169,6 +208,7 @@ export class CanvasManager { onZoom: (delta, cx, cy) => { this.#viewport.applyZoom(delta, cx, cy); this.#renderer.drawFrame(); + this.#uvRenderer?.update(); }, onColorPick: (tx, ty) => { const [r, g, b, a] = this.#textureBuffer.samplePixel(tx, ty); @@ -184,7 +224,32 @@ export class CanvasManager { else if (this.#input.getMode() === "paint") { this.#svgManager.updateBrushHighlight(cx, cy); } - } + }, + onUVMouseDown: (cx, cy, button) => { + this.#uvInputHandler?.onMouseDown(cx, cy, button); + }, + onUVMouseMove: (cx, cy) => { + this.#uvInputHandler?.onMouseMove(cx, cy); + }, + onUVMouseUp: () => { + this.#uvInputHandler?.onMouseUp(); + }, + onKeyDown: options.uv + ? (event) => { + if (this.#input.getMode() !== "uv") { + return; + } + if (event.ctrlKey && event.key === "z" && !event.shiftKey) { + options.uv!.map.undo(); + } + else if (event.ctrlKey && (event.key === "y" || (event.shiftKey && event.key === "z"))) { + options.uv!.map.redo(); + } + else if (event.key === "Delete" || event.key === "Backspace") { + this.#uvInputHandler?.onDeleteKey(); + } + } + : undefined } }); @@ -199,7 +264,7 @@ export class CanvasManager { mode: Mode ): void { this.#input.setMode(mode); - if (mode === "move") { + if (mode === "move" || mode === "uv") { this.#svgManager.hideSvgHighlight(); } } @@ -240,6 +305,7 @@ export class CanvasManager { this.#viewport.setTextureSize(size); this.#svgManager.setTextureSize(size); this.#renderer.drawFrame(); + this.#uvRenderer?.update(); } getCamera(): Vec2 { @@ -266,6 +332,7 @@ export class CanvasManager { this.#viewport.resizeCanvas(bounds.width, bounds.height); this.#svgManager.updateSvgSize(bounds.width, bounds.height); this.#renderer.drawFrame(); + this.#uvRenderer?.update(); } getTextureCanvas(): HTMLCanvasElement { @@ -274,6 +341,8 @@ export class CanvasManager { destroy(): void { this.#input.destroy(); + this.#uvInputHandler?.destroy(); + this.#uvRenderer?.destroy(); const rendererCanvas = this.#renderer.getCanvas(); if (rendererCanvas.parentElement) { rendererCanvas.remove(); diff --git a/packages/pixel-draw-renderer/src/InputController.ts b/packages/pixel-draw-renderer/src/InputController.ts index 2915946..c362f6b 100644 --- a/packages/pixel-draw-renderer/src/InputController.ts +++ b/packages/pixel-draw-renderer/src/InputController.ts @@ -12,6 +12,14 @@ export interface InputActions { onZoom(delta: number, cx: number, cy: number): void; onColorPick(tx: number, ty: number): void; onMouseMove(cx: number, cy: number): void; + /** Called on left-button mousedown when mode is "uv". */ + onUVMouseDown?(cx: number, cy: number, button: number): void; + /** Called on mousemove when mode is "uv". */ + onUVMouseMove?(cx: number, cy: number): void; + /** Called on mouseup when mode is "uv". */ + onUVMouseUp?(): void; + /** Called for keydown events (when provided). */ + onKeyDown?(event: KeyboardEvent): void; } export interface InputControllerOptions { @@ -47,6 +55,7 @@ export class InputController { #onContextMenu: (event: MouseEvent) => void; #onWindowMouseMove: (event: MouseEvent) => void; #onWindowMouseUp: (event: MouseEvent) => void; + #onKeyDown: ((event: KeyboardEvent) => void) | null = null; constructor( options: InputControllerOptions @@ -80,6 +89,11 @@ export class InputController { this.#canvas.addEventListener("contextmenu", this.#onContextMenu); window.addEventListener("mousemove", this.#onWindowMouseMove); window.addEventListener("mouseup", this.#onWindowMouseUp); + + if (actions.onKeyDown) { + this.#onKeyDown = (event) => actions.onKeyDown!(event); + window.addEventListener("keydown", this.#onKeyDown); + } } getMode(): Mode { @@ -101,6 +115,9 @@ export class InputController { this.#canvas.removeEventListener("contextmenu", this.#onContextMenu); window.removeEventListener("mousemove", this.#onWindowMouseMove); window.removeEventListener("mouseup", this.#onWindowMouseUp); + if (this.#onKeyDown) { + window.removeEventListener("keydown", this.#onKeyDown); + } } #handleMouseDown( @@ -116,6 +133,11 @@ export class InputController { } } + if (this.#mode === "uv" && event.button === 0) { + const canvasPos = this.#viewport.getMouseCanvasPosition(event.clientX, event.clientY, bounds); + this.#actions.onUVMouseDown?.(canvasPos.x, canvasPos.y, event.button); + } + if (event.button === 1) { this.#isPanning = true; this.#panStart = { x: event.clientX, y: event.clientY }; @@ -142,6 +164,10 @@ export class InputController { this.#actions.onDrawMove(pos.x, pos.y); } } + + if (this.#mode === "uv") { + this.#actions.onUVMouseMove?.(canvasPos.x, canvasPos.y); + } } #handleMouseLeave( @@ -157,6 +183,10 @@ export class InputController { this.#isDrawing = false; this.#actions.onDrawEnd(); } + + if (this.#mode === "uv") { + this.#actions.onUVMouseUp?.(); + } } #handleWheel( @@ -221,5 +251,9 @@ export class InputController { this.#isDrawing = false; this.#actions.onDrawEnd(); } + + if (this.#mode === "uv") { + this.#actions.onUVMouseUp?.(); + } } } diff --git a/packages/pixel-draw-renderer/src/SvgManager.ts b/packages/pixel-draw-renderer/src/SvgManager.ts index 0865105..bb668a1 100644 --- a/packages/pixel-draw-renderer/src/SvgManager.ts +++ b/packages/pixel-draw-renderer/src/SvgManager.ts @@ -128,6 +128,10 @@ export class SvgManager { this.#highlightElements.setAttribute("visibility", "visible"); } + getSvgElement(): SVGElement { + return this.#svg; + } + hideSvgHighlight(): void { this.#highlightElements.setAttribute("visibility", "hidden"); } diff --git a/packages/pixel-draw-renderer/src/UVHistory.ts b/packages/pixel-draw-renderer/src/UVHistory.ts new file mode 100644 index 0000000..ac185e3 --- /dev/null +++ b/packages/pixel-draw-renderer/src/UVHistory.ts @@ -0,0 +1,98 @@ +// CONSTANTS +const kDefaultMaxSize = 100; + +export interface UVCommand { + readonly type: string; + readonly regionId: string; + /** Apply (or re-apply for redo) the change. */ + execute(): void; + /** Reverse the change. */ + undo(): void; + /** + * Called on the stack-top command with an incoming command of the same + * logical operation (e.g. continuous drag). Implementations should absorb + * the incoming command's delta and return true to prevent a new entry being + * pushed to the stack. + */ + tryMerge?(incoming: UVCommand): boolean; +} + +export interface UVHistoryOptions { + maxSize?: number; +} + +/** + * Command-pattern undo/redo stack for UV region mutations. + * Callers push commands AFTER already applying the change to the data model; + * `push` itself does NOT call `execute()`. The `execute()` method is reserved + * for `redo()`. + */ +export class UVHistory { + readonly maxSize: number; + + #undoStack: UVCommand[] = []; + #redoStack: UVCommand[] = []; + + constructor( + options?: UVHistoryOptions + ) { + this.maxSize = options?.maxSize ?? kDefaultMaxSize; + } + + /** + * Record a command that has already been applied. + * If the stack top accepts the incoming command via `tryMerge`, the incoming + * command is absorbed (no new entry). Otherwise it is appended. + * Pushing always clears the redo stack. + */ + push( + command: UVCommand + ): void { + this.#redoStack = []; + + if (this.#undoStack.length > 0) { + const top = this.#undoStack[this.#undoStack.length - 1]; + if (top.tryMerge?.(command)) { + return; + } + } + + this.#undoStack.push(command); + if (this.#undoStack.length > this.maxSize) { + this.#undoStack.shift(); + } + } + + undo(): void { + const command = this.#undoStack.pop(); + if (!command) { + return; + } + + command.undo(); + this.#redoStack.push(command); + } + + redo(): void { + const command = this.#redoStack.pop(); + if (!command) { + return; + } + + command.execute(); + this.#undoStack.push(command); + } + + get canUndo(): boolean { + return this.#undoStack.length > 0; + } + + get canRedo(): boolean { + return this.#redoStack.length > 0; + } + + clear(): void { + this.#undoStack = []; + this.#redoStack = []; + } +} diff --git a/packages/pixel-draw-renderer/src/UVInputHandler.ts b/packages/pixel-draw-renderer/src/UVInputHandler.ts new file mode 100644 index 0000000..33c585f --- /dev/null +++ b/packages/pixel-draw-renderer/src/UVInputHandler.ts @@ -0,0 +1,378 @@ +// Import Internal Dependencies +import type { + UVHandle, + UVHandleType, + Vec2 +} from "./types.ts"; +import type { Viewport } from "./Viewport.ts"; +import type { UVMap } from "./UVMap.ts"; +import type { UVRenderer } from "./UVRenderer.ts"; + +// CONSTANTS +const kHitRadius = 8; +const kMinRegionSize = 0.01; +const kDefaultEdgeSnapThreshold = 0.02; +const kHandleTypes: readonly UVHandleType[] = [ + "corner-tl", + "corner-tr", + "corner-bl", + "corner-br", + "edge-t", + "edge-b", + "edge-l", + "edge-r" +]; + +export interface UVSnappingOptions { + /** + * Snap u, v coordinates to the nearest texture pixel boundary. + * @default true + **/ + pixelSnap: boolean; + /** + * Snap edges to other region edges when within edgeSnapThreshold. + * @default false + **/ + edgeSnap: boolean; + /** + * Normalized UV distance within which edge snapping activates. + * @default 0.02 + **/ + edgeSnapThreshold: number; +} + +export interface UVInputHandlerOptions { + viewport: Viewport; + uvMap: UVMap; + uvRenderer: UVRenderer; + textureSize: Vec2; + snapping?: Partial; + onRegionSelected?: (id: string | null) => void; +} + +type InteractionState = + | { kind: "idle"; } + | { kind: "creating"; startU: number; startV: number; currentU: number; currentV: number; } + | { kind: "moving"; regionId: string; lastU: number; lastV: number; } + | { kind: "resizing"; regionId: string; handle: UVHandleType; lastU: number; lastV: number; }; + +/** + * UV-mode state machine. + * Receives canvas-space coordinates from InputController callbacks and + * translates them into UVMap mutations. + */ +export class UVInputHandler { + #uvMap: UVMap; + #uvRenderer: UVRenderer; + #textureSize: Vec2; + #snapping: UVSnappingOptions; + #onRegionSelected: ((id: string | null) => void) | undefined; + #state: InteractionState = { kind: "idle" }; + + constructor( + options: UVInputHandlerOptions + ) { + this.#uvMap = options.uvMap; + this.#uvRenderer = options.uvRenderer; + this.#textureSize = options.textureSize; + this.#onRegionSelected = options.onRegionSelected; + + this.#snapping = { + pixelSnap: options.snapping?.pixelSnap ?? true, + edgeSnap: options.snapping?.edgeSnap ?? false, + edgeSnapThreshold: options.snapping?.edgeSnapThreshold ?? kDefaultEdgeSnapThreshold + }; + } + + onMouseDown( + canvasX: number, + canvasY: number, + button: number + ): void { + if (button !== 0) { + return; + } + + // Hit-test handles first (only for selected region) + const handle = this.hitTestHandle(canvasX, canvasY); + if (handle) { + const uv = this.#uvRenderer.svgToUV(canvasX, canvasY); + const snapped = this.snapUV(uv.x, uv.y); + this.#state = { + kind: "resizing", + regionId: handle.regionId, + handle: handle.type, + lastU: snapped.x, + lastV: snapped.y + }; + + return; + } + + // Hit-test region bodies + const regionId = this.hitTestRegion(canvasX, canvasY); + if (regionId) { + this.#uvMap.select(regionId); + this.#onRegionSelected?.(regionId); + + const uv = this.#uvRenderer.svgToUV(canvasX, canvasY); + const snapped = this.snapUV(uv.x, uv.y); + this.#state = { + kind: "moving", + regionId, + lastU: snapped.x, + lastV: snapped.y + }; + + return; + } + + // Empty space — start creating + this.#uvMap.select(null); + this.#onRegionSelected?.(null); + + const uv = this.#uvRenderer.svgToUV(canvasX, canvasY); + const snapped = this.snapUV(uv.x, uv.y); + this.#state = { + kind: "creating", + startU: snapped.x, + startV: snapped.y, + currentU: snapped.x, + currentV: snapped.y + }; + } + + onMouseMove( + canvasX: number, + canvasY: number + ): void { + const uv = this.#uvRenderer.svgToUV(canvasX, canvasY); + const snapped = this.snapUV(uv.x, uv.y); + + switch (this.#state.kind) { + case "moving": { + const { regionId } = this.#state; + const du = snapped.x - this.#state.lastU; + const dv = snapped.y - this.#state.lastV; + if (du !== 0 || dv !== 0) { + this.#state = { ...this.#state, lastU: snapped.x, lastV: snapped.y }; + this.#uvMap.moveRegion(regionId, du, dv); + } + + break; + } + case "resizing": { + const { regionId, handle } = this.#state; + const du = snapped.x - this.#state.lastU; + const dv = snapped.y - this.#state.lastV; + if (du !== 0 || dv !== 0) { + this.#state = { ...this.#state, lastU: snapped.x, lastV: snapped.y }; + this.#uvMap.resizeRegion(regionId, handle, { du, dv }); + } + + break; + } + case "creating": { + this.#state = { ...this.#state, currentU: snapped.x, currentV: snapped.y }; + this.#uvRenderer.setDragPreview(this.#buildPreviewData()); + + break; + } + default: + break; + } + } + + onMouseUp(): void { + if (this.#state.kind === "creating") { + const preview = this.#buildPreviewData(); + this.#uvRenderer.setDragPreview(null); + + if ( + preview && + preview.width >= kMinRegionSize && + preview.height >= kMinRegionSize + ) { + const region = this.#uvMap.createRegion(preview); + this.#uvMap.select(region.id); + this.#onRegionSelected?.(region.id); + } + } + + this.#state = { kind: "idle" }; + } + + onDeleteKey(): void { + const selected = this.#uvMap.selectedId; + if (selected) { + this.#uvMap.deleteRegion(selected); + this.#onRegionSelected?.(null); + } + } + + /** + * Return the handle under the given canvas position, or `null`. + * Only handles of the currently selected region are checked. + */ + hitTestHandle( + cx: number, + cy: number + ): UVHandle | null { + const selectedId = this.#uvMap.selectedId; + if (!selectedId) { + return null; + } + const region = this.#uvMap.get(selectedId); + if (!region) { + return null; + } + + const positions = this.#getHandlePositions(region.u, region.v, { + width: region.width, + height: region.height + }); + + for (const ht of kHandleTypes) { + const pos = positions[ht]; + if (!pos) { + continue; + } + + const dx = cx - pos.x; + const dy = cy - pos.y; + if (Math.sqrt(dx * dx + dy * dy) <= kHitRadius) { + return { regionId: selectedId, type: ht }; + } + } + + return null; + } + + /** + * Return the id of the topmost region containing the given canvas position, + * or `null` if none. + */ + hitTestRegion( + cx: number, + cy: number + ): string | null { + const uv = this.#uvRenderer.svgToUV(cx, cy); + + // Iterate in reverse insertion order so later-added regions are "on top" + const regionIds = [...this.#uvMap] + .map((region) => region.id) + .reverse(); + for (const id of regionIds) { + const r = this.#uvMap.get(id); + if (!r) { + continue; + } + if ( + uv.x >= r.u && + uv.x <= r.u + r.width && + uv.y >= r.v && + uv.y <= r.v + r.height + ) { + return id; + } + } + + return null; + } + + /** + * Snap a UV coordinate according to current snapping options. + * Pixel snap is applied first, then edge snap (if enabled). + */ + snapUV( + u: number, + v: number + ): Vec2 { + let su = u; + let sv = v; + + if (this.#snapping.pixelSnap) { + const tw = this.#textureSize.x; + const th = this.#textureSize.y; + su = Math.round(su * tw) / tw; + sv = Math.round(sv * th) / th; + } + + if (this.#snapping.edgeSnap) { + const threshold = this.#snapping.edgeSnapThreshold; + const excludeId = + this.#state.kind === "moving" || this.#state.kind === "resizing" + ? this.#state.regionId + : undefined; + + for (const region of this.#uvMap) { + if (region.id === excludeId) { + continue; + } + const edges = [region.u, region.u + region.width]; + for (const eu of edges) { + if (Math.abs(su - eu) < threshold) { + su = eu; + break; + } + } + const edgesV = [region.v, region.v + region.height]; + for (const ev of edgesV) { + if (Math.abs(sv - ev) < threshold) { + sv = ev; + break; + } + } + } + } + + return { x: su, y: sv }; + } + + destroy(): void { + this.#state = { kind: "idle" }; + this.#uvRenderer.setDragPreview(null); + } + + #getHandlePositions( + u: number, + v: number, + options: { width: number; height: number; } + ): Partial> { + const { width, height } = options; + const toSvg = (pu: number, pv: number) => this.#uvRenderer.uvToSvg(pu, pv); + + return { + "corner-tl": toSvg(u, v), + "corner-tr": toSvg(u + width, v), + "corner-bl": toSvg(u, v + height), + "corner-br": toSvg(u + width, v + height), + "edge-t": toSvg(u + width / 2, v), + "edge-b": toSvg(u + width / 2, v + height), + "edge-l": toSvg(u, v + height / 2), + "edge-r": toSvg(u + width, v + height / 2) + }; + } + + #buildPreviewData() { + if (this.#state.kind !== "creating") { + return null; + } + + const { startU, startV, currentU, currentV } = this.#state; + const u = Math.min(startU, currentU); + const v = Math.min(startV, currentV); + const width = Math.abs(currentU - startU); + const height = Math.abs(currentV - startV); + + return { + id: "__preview__", + label: "", + u, + v, + width, + height, + color: "#4af" + }; + } +} diff --git a/packages/pixel-draw-renderer/src/UVMap.ts b/packages/pixel-draw-renderer/src/UVMap.ts new file mode 100644 index 0000000..d41e44d --- /dev/null +++ b/packages/pixel-draw-renderer/src/UVMap.ts @@ -0,0 +1,421 @@ +// Import Internal Dependencies +import type { UVHandleType } from "./types.ts"; +import { + UVRegion, + type UVRegionData +} from "./UVRegion.ts"; +import { + UVHistory, + type UVCommand +} from "./UVHistory.ts"; + +export interface UVMapChangedDetail { + type: "add" | "remove" | "move" | "resize" | "label" | "select"; + regionId: string | null; +} + +// CONSTANTS +const kDefaultColor = "#4af"; + +export interface UVMapOptions { + maxHistorySize?: number; +} + +export class UVMap extends EventTarget { + #regions: Map = new Map(); + #selectedId: string | null = null; + #history: UVHistory; + #nextId: number = 0; + + constructor( + options?: UVMapOptions + ) { + super(); + this.#history = new UVHistory({ + maxSize: options?.maxHistorySize + }); + } + + // ──────────────────────────── Region CRUD ──────────────────────────── + + /** + * Add a region directly without recording to history. + * Use `createRegion` for undoable creation. + */ + add( + data: Omit & { id?: string; color?: string; } + ): UVRegion { + const id = data.id ?? `uv-${this.#nextId++}`; + const region = new UVRegion({ ...data, id, color: data.color ?? kDefaultColor }); + this.#regions.set(id, region); + + return region; + } + + /** + * Remove a region directly without recording to history. + * Use `deleteRegion` for undoable removal. + */ + remove(id: string): boolean { + const deleted = this.#regions.delete(id); + if (deleted && this.#selectedId === id) { + this.#selectedId = null; + } + + return deleted; + } + + get(id: string): UVRegion | undefined { + return this.#regions.get(id); + } + + has(id: string): boolean { + return this.#regions.has(id); + } + + [Symbol.iterator](): IterableIterator { + return this.#regions.values(); + } + + get size(): number { + return this.#regions.size; + } + + get selectedId(): string | null { + return this.#selectedId; + } + + select(id: string | null): void { + this.#selectedId = id; + this.#emitChanged("select", id); + } + + createRegion( + data: Omit & { id?: string; color?: string; } + ): UVRegion { + const id = data.id ?? `uv-${this.#nextId++}`; + const region = new UVRegion({ ...data, id, color: data.color ?? kDefaultColor }); + this.#regions.set(id, region); + this.#emitChanged("add", id); + + this.#history.push({ + type: "create", + regionId: id, + execute: () => { + this.#regions.set(id, new UVRegion(region.toData())); + this.#emitChanged("add", id); + }, + undo: () => { + this.#regions.delete(id); + if (this.#selectedId === id) { + this.#selectedId = null; + } + this.#emitChanged("remove", id); + } + }); + + return region; + } + + deleteRegion( + id: string + ): void { + const region = this.#regions.get(id); + if (!region) { + return; + } + + const snapshot = region.toData(); + this.#regions.delete(id); + if (this.#selectedId === id) { + this.#selectedId = null; + } + this.#emitChanged("remove", id); + + this.#history.push({ + type: "delete", + regionId: id, + execute: () => { + this.#regions.delete(id); + if (this.#selectedId === id) { + this.#selectedId = null; + } + this.#emitChanged("remove", id); + }, + undo: () => { + this.#regions.set(id, new UVRegion(snapshot)); + this.#emitChanged("add", id); + } + }); + } + + /** + * Move a region by a normalized UV delta and record to history. + * Consecutive moves on the same region are coalesced into one history entry. + */ + moveRegion( + id: string, + du: number, + dv: number + ): void { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.u += du; + region.v += dv; + region.clamp(); + this.#emitChanged("move", id); + + // Mutable accumulated delta — mutated by tryMerge + let totalDu = du; + let totalDv = dv; + + const cmd: UVCommand & { readonly _du: number; readonly _dv: number; } = { + type: "move", + regionId: id, + _du: du, + _dv: dv, + execute: () => { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.u += totalDu; + region.v += totalDv; + region.clamp(); + this.#emitChanged("move", id); + }, + undo: () => { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.u -= totalDu; + region.v -= totalDv; + region.clamp(); + this.#emitChanged("move", id); + }, + tryMerge: (incoming) => { + if (incoming.type !== "move" || incoming.regionId !== id) { + return false; + } + + const incomingMove = incoming as typeof cmd; + totalDu += incomingMove._du; + totalDv += incomingMove._dv; + + return true; + } + }; + + this.#history.push(cmd); + } + + /** + * Resize a region by moving one of its handles and record to history. + * Consecutive resizes with the same handle are coalesced. + */ + resizeRegion( + id: string, + handle: UVHandleType, + options: { du: number; dv: number; } + ): void { + const { du, dv } = options; + const region = this.#regions.get(id); + if (!region) { + return; + } + + const before = region.toData(); + applyHandleDelta(region, handle, { du, dv }); + region.clamp(); + this.#emitChanged("resize", id); + + // The "after" snapshot is updated by tryMerge as the drag continues. + let afterData = region.toData(); + + const cmd: UVCommand & { + readonly _handle: UVHandleType; + readonly _du: number; + readonly _dv: number; + } = { + type: "resize", + regionId: id, + _handle: handle, + _du: du, + _dv: dv, + execute: () => { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.fromData(afterData); + this.#emitChanged("resize", id); + }, + undo: () => { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.fromData(before); + this.#emitChanged("resize", id); + }, + tryMerge: (incoming) => { + if (incoming.type !== "resize" || incoming.regionId !== id) { + return false; + } + const incomingResize = incoming as typeof cmd; + if (incomingResize._handle !== handle) { + return false; + } + + // Incoming has already been applied by the caller — capture current state. + const region = this.#regions.get(id); + if (region) { + afterData = region.toData(); + } + + return true; + } + }; + + this.#history.push(cmd); + } + + /** Rename a region and record to history. */ + setLabel( + id: string, + label: string + ): void { + const region = this.#regions.get(id); + if (!region) { + return; + } + + const oldLabel = region.label; + region.label = label; + this.#emitChanged("label", id); + + this.#history.push({ + type: "label", + regionId: id, + execute: () => { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.label = label; + this.#emitChanged("label", id); + }, + undo: () => { + const region = this.#regions.get(id); + if (!region) { + return; + } + + region.label = oldLabel; + this.#emitChanged("label", id); + } + }); + } + + undo(): void { + this.#history.undo(); + } + + redo(): void { + this.#history.redo(); + } + + get canUndo(): boolean { + return this.#history.canUndo; + } + + get canRedo(): boolean { + return this.#history.canRedo; + } + + toJSON(): UVRegionData[] { + return [ + ...this.#regions.values() + ].map((region) => region.toData()); + } + + static fromJSON( + data: UVRegionData[] + ): UVMap { + const map = new UVMap(); + for (const regionData of data) { + map.add(regionData); + } + + return map; + } + + #emitChanged( + type: UVMapChangedDetail["type"], + regionId: string | null + ): void { + this.dispatchEvent( + new CustomEvent("changed", { + detail: { type, regionId } + }) + ); + } +} + +function applyHandleDelta( + region: UVRegion, + handle: UVHandleType, + options: { du: number; dv: number; } +): void { + const { du, dv } = options; + + switch (handle) { + case "corner-tl": + region.u += du; + region.v += dv; + region.width -= du; + region.height -= dv; + break; + case "corner-tr": + region.v += dv; + region.width += du; + region.height -= dv; + break; + case "corner-bl": + region.u += du; + region.width -= du; + region.height += dv; + break; + case "corner-br": + region.width += du; + region.height += dv; + break; + case "edge-t": + region.v += dv; + region.height -= dv; + break; + case "edge-b": + region.height += dv; + break; + case "edge-l": + region.u += du; + region.width -= du; + break; + case "edge-r": + region.width += du; + break; + case "body": + region.u += du; + region.v += dv; + break; + } +} diff --git a/packages/pixel-draw-renderer/src/UVRegion.ts b/packages/pixel-draw-renderer/src/UVRegion.ts new file mode 100644 index 0000000..e73e3d1 --- /dev/null +++ b/packages/pixel-draw-renderer/src/UVRegion.ts @@ -0,0 +1,77 @@ +export interface UVRegionData { + id: string; + label: string; + /** Left edge, normalized [0, 1], origin top-left. */ + u: number; + /** Top edge, normalized [0, 1], origin top-left. */ + v: number; + width: number; + height: number; + /** CSS color used for the SVG overlay tint. */ + color: string; +} + +export class UVRegion { + id: string; + label: string; + u: number; + v: number; + width: number; + height: number; + color: string; + + constructor( + data: UVRegionData + ) { + this.id = data.id; + this.label = data.label; + this.u = data.u; + this.v = data.v; + this.width = data.width; + this.height = data.height; + this.color = data.color; + } + + /** Clamp all coordinates so region stays inside [0, 1]×[0, 1]. */ + clamp(): void { + this.u = Math.max(0, Math.min(1, this.u)); + this.v = Math.max(0, Math.min(1, this.v)); + this.width = Math.max(0, Math.min(1 - this.u, this.width)); + this.height = Math.max(0, Math.min(1 - this.v, this.height)); + } + + /** Snap u, v, width, height to the nearest pixel boundary. */ + snapToPixel( + texW: number, + texH: number + ): void { + this.u = Math.round(this.u * texW) / texW; + this.v = Math.round(this.v * texH) / texH; + this.width = Math.round(this.width * texW) / texW; + this.height = Math.round(this.height * texH) / texH; + } + + toData(): UVRegionData { + return { + id: this.id, + label: this.label, + u: this.u, + v: this.v, + width: this.width, + height: this.height, + color: this.color + }; + } + + fromData( + data: UVRegionData + ): void { + this.id = data.id; + this.label = data.label; + this.u = data.u; + this.v = data.v; + this.width = data.width; + this.height = data.height; + this.color = data.color; + } +} diff --git a/packages/pixel-draw-renderer/src/UVRenderer.ts b/packages/pixel-draw-renderer/src/UVRenderer.ts new file mode 100644 index 0000000..81bbbe5 --- /dev/null +++ b/packages/pixel-draw-renderer/src/UVRenderer.ts @@ -0,0 +1,376 @@ +// Import Internal Dependencies +import type { + DefaultViewport, + Vec2, + UVHandleType +} from "./types.ts"; +import type { + UVMap, + UVMapChangedDetail +} from "./UVMap.ts"; +import type { UVRegionData } from "./UVRegion.ts"; + +// CONSTANTS +const kSvgNs = "http://www.w3.org/2000/svg"; +const kHandleRadius = 4; +const kHandleTypes: readonly UVHandleType[] = [ + "corner-tl", + "corner-tr", + "corner-bl", + "corner-br", + "edge-t", + "edge-b", + "edge-l", + "edge-r" +]; + +export interface UVRendererOptions { + /** The root SVG element from SvgManager.getSvgElement(). */ + svg: SVGElement; + uvMap: UVMap; + viewport: DefaultViewport; + textureSize: Vec2; +} + +/** + * Renders UV regions as an SVG overlay on top of the canvas. + * All elements use `pointer-events: none` — hit-testing is handled by UVInputHandler. + */ +export class UVRenderer { + #svg: SVGElement; + #uvMap: UVMap; + #viewport: DefaultViewport; + #textureSize: Vec2; + #overlayGroup: SVGGElement; + #previewGroup: SVGGElement; + #regionGroups: Map = new Map(); + #onChanged: (event: Event) => void; + #dragPreview: UVRegionData | null = null; + + constructor( + options: UVRendererOptions + ) { + this.#svg = options.svg; + this.#uvMap = options.uvMap; + this.#viewport = options.viewport; + this.#textureSize = options.textureSize; + + this.#overlayGroup = this.#createGroup("uv-overlay"); + this.#svg.appendChild(this.#overlayGroup); + + this.#previewGroup = this.#createGroup("uv-preview"); + this.#svg.appendChild(this.#previewGroup); + + // Seed from existing regions + for (const region of this.#uvMap) { + this.#addRegionGroup(region.toData()); + } + this.#applyAttributes(); + + this.#onChanged = (e: Event) => this.#handleChanged(e as CustomEvent); + this.#uvMap.addEventListener("changed", this.#onChanged); + } + + // ──────────────────────────── Public API ───────────────────────────── + + /** Recompute all SVG positions after a pan, zoom, or resize. */ + update(): void { + this.#applyAttributes(); + if (this.#dragPreview) { + this.#updatePreview(); + } + } + + /** Convert normalized UV coordinates to canvas-space SVG pixel coordinates. */ + uvToSvg(u: number, v: number): Vec2 { + const { zoom, camera } = this.#viewport; + + return { + x: camera.x + u * this.#textureSize.x * zoom, + y: camera.y + v * this.#textureSize.y * zoom + }; + } + + /** Convert canvas-space SVG pixel coordinates to normalized UV. */ + svgToUV( + cx: number, + cy: number + ): Vec2 { + const { zoom, camera } = this.#viewport; + const scaleX = this.#textureSize.x * zoom; + const scaleY = this.#textureSize.y * zoom; + + return { + x: scaleX === 0 ? 0 : (cx - camera.x) / scaleX, + y: scaleY === 0 ? 0 : (cy - camera.y) / scaleY + }; + } + + /** + * Show a drag-preview rectangle while the user is drawing a new region. + * Pass `null` to clear the preview. + */ + setDragPreview( + data: UVRegionData | null + ): void { + this.#dragPreview = data; + this.#updatePreview(); + } + + destroy(): void { + this.#uvMap.removeEventListener("changed", this.#onChanged); + if (this.#overlayGroup.parentElement) { + this.#overlayGroup.remove(); + } + if (this.#previewGroup.parentElement) { + this.#previewGroup.remove(); + } + this.#regionGroups.clear(); + } + + // ──────────────────────────── Private ──────────────────────────────── + + #handleChanged( + event: CustomEvent + ): void { + const { type, regionId } = event.detail; + + switch (type) { + case "add": { + if (!regionId) { + break; + } + const region = this.#uvMap.get(regionId); + if (region) { + this.#addRegionGroup(region.toData()); + this.#applyGroupAttributes(regionId); + } + break; + } + case "remove": { + if (!regionId) { + break; + } + const g = this.#regionGroups.get(regionId); + if (g) { + g.remove(); + this.#regionGroups.delete(regionId); + } + break; + } + case "select": { + // Toggle handle visibility for old and new selected region + for (const [id, g] of this.#regionGroups) { + const isSelected = id === this.#uvMap.selectedId; + this.#setHandlesVisible(g, isSelected); + } + break; + } + case "move": + case "resize": + case "label": { + if (regionId) { + this.#applyGroupAttributes(regionId); + } + break; + } + } + } + + #addRegionGroup( + data: UVRegionData + ): void { + const g = document.createElementNS(kSvgNs, "g") as SVGGElement; + g.id = `uv-region-${data.id}`; + g.style.pointerEvents = "none"; + + // Fill rect + const fill = document.createElementNS(kSvgNs, "rect"); + fill.classList.add("uv-fill"); + fill.setAttribute("fill-opacity", "0.25"); + fill.setAttribute("fill", data.color); + fill.style.pointerEvents = "none"; + g.appendChild(fill); + + // Border rect + const border = document.createElementNS(kSvgNs, "rect"); + border.classList.add("uv-border"); + border.setAttribute("fill", "none"); + border.setAttribute("stroke", data.color); + border.setAttribute("stroke-width", "1"); + border.style.pointerEvents = "none"; + g.appendChild(border); + + // Label + const label = document.createElementNS(kSvgNs, "text"); + label.classList.add("uv-label"); + label.setAttribute("font-size", "10"); + label.setAttribute("fill", data.color); + label.style.pointerEvents = "none"; + label.style.userSelect = "none"; + label.textContent = data.label; + g.appendChild(label); + + // Handles (hidden by default) + for (const handleType of kHandleTypes) { + const circle = document.createElementNS(kSvgNs, "circle"); + circle.classList.add("uv-handle", handleType); + circle.setAttribute("r", String(kHandleRadius)); + circle.setAttribute("fill", data.color); + circle.setAttribute("stroke", "#fff"); + circle.setAttribute("stroke-width", "1"); + circle.style.pointerEvents = "none"; + circle.style.visibility = "hidden"; + g.appendChild(circle); + } + + this.#overlayGroup.appendChild(g); + this.#regionGroups.set(data.id, g); + } + + #applyAttributes(): void { + for (const id of this.#regionGroups.keys()) { + this.#applyGroupAttributes(id); + } + } + + #applyGroupAttributes( + id: string + ): void { + const region = this.#uvMap.get(id); + const g = this.#regionGroups.get(id); + if (!region || !g) { + return; + } + + const { x: sx, y: sy } = this.uvToSvg(region.u, region.v); + const { zoom } = this.#viewport; + const sw = region.width * this.#textureSize.x * zoom; + const sh = region.height * this.#textureSize.y * zoom; + + const fill = g.querySelector(".uv-fill") as SVGRectElement | null; + if (fill) { + fill.setAttribute("x", String(sx)); + fill.setAttribute("y", String(sy)); + fill.setAttribute("width", String(sw)); + fill.setAttribute("height", String(sh)); + fill.setAttribute("fill", region.color); + } + + const border = g.querySelector(".uv-border") as SVGRectElement | null; + if (border) { + border.setAttribute("x", String(sx)); + border.setAttribute("y", String(sy)); + border.setAttribute("width", String(sw)); + border.setAttribute("height", String(sh)); + border.setAttribute("stroke", region.color); + } + + const label = g.querySelector(".uv-label") as SVGTextElement | null; + if (label) { + label.setAttribute("x", String(sx + 2)); + label.setAttribute("y", String(sy + 12)); + label.setAttribute("fill", region.color); + label.textContent = region.label; + } + + const handlePositions = this.#getHandlePositions(region.u, region.v, { + width: region.width, + height: region.height + }); + const circles = g.querySelectorAll(".uv-handle"); + circles.forEach((circle, index) => { + const ht = kHandleTypes[index]; + if (!ht) { + return; + } + const pos = handlePositions[ht]; + if (pos) { + circle.setAttribute("cx", String(pos.x)); + circle.setAttribute("cy", String(pos.y)); + circle.setAttribute("fill", region.color); + } + }); + + const isSelected = this.#uvMap.selectedId === id; + this.#setHandlesVisible(g, isSelected); + } + + #getHandlePositions( + u: number, + v: number, + options: { width: number; height: number; } + ): Record { + const { width, height } = options; + + const tl = this.uvToSvg(u, v); + const tr = this.uvToSvg(u + width, v); + const bl = this.uvToSvg(u, v + height); + const br = this.uvToSvg(u + width, v + height); + const tc = this.uvToSvg(u + width / 2, v); + const bc = this.uvToSvg(u + width / 2, v + height); + const ml = this.uvToSvg(u, v + height / 2); + const mr = this.uvToSvg(u + width, v + height / 2); + + return { + "corner-tl": tl, + "corner-tr": tr, + "corner-bl": bl, + "corner-br": br, + "edge-t": tc, + "edge-b": bc, + "edge-l": ml, + "edge-r": mr, + body: this.uvToSvg(u + width / 2, v + height / 2) + }; + } + + #setHandlesVisible( + svgElement: SVGGElement, + visible: boolean + ): void { + const circles = svgElement.querySelectorAll(".uv-handle"); + circles.forEach((circle) => { + circle.style.visibility = visible ? "visible" : "hidden"; + }); + } + + #updatePreview(): void { + // Remove existing preview children + while (this.#previewGroup.firstChild) { + this.#previewGroup.removeChild(this.#previewGroup.firstChild); + } + + if (!this.#dragPreview) { + return; + } + const data = this.#dragPreview; + const { x: sx, y: sy } = this.uvToSvg(data.u, data.v); + const { zoom } = this.#viewport; + const sw = data.width * this.#textureSize.x * zoom; + const sh = data.height * this.#textureSize.y * zoom; + + const rect = document.createElementNS(kSvgNs, "rect"); + rect.setAttribute("x", String(sx)); + rect.setAttribute("y", String(sy)); + rect.setAttribute("width", String(sw)); + rect.setAttribute("height", String(sh)); + rect.setAttribute("fill", data.color); + rect.setAttribute("fill-opacity", "0.15"); + rect.setAttribute("stroke", data.color); + rect.setAttribute("stroke-width", "1"); + rect.setAttribute("stroke-dasharray", "4 2"); + rect.style.pointerEvents = "none"; + this.#previewGroup.appendChild(rect); + } + + #createGroup( + id: string + ): SVGGElement { + const svgElement = document.createElementNS(kSvgNs, "g") as SVGGElement; + svgElement.id = id; + svgElement.style.pointerEvents = "none"; + + return svgElement; + } +} diff --git a/packages/pixel-draw-renderer/src/index.ts b/packages/pixel-draw-renderer/src/index.ts index 81e5657..d188c35 100644 --- a/packages/pixel-draw-renderer/src/index.ts +++ b/packages/pixel-draw-renderer/src/index.ts @@ -28,9 +28,32 @@ export { Viewport, type ViewportOptions } from "./Viewport.ts"; +export { + UVRegion, + type UVRegionData +} from "./UVRegion.ts"; +export { + UVMap, + type UVMapChangedDetail +} from "./UVMap.ts"; +export { + UVHistory, + type UVCommand +} from "./UVHistory.ts"; +export { + UVRenderer, + type UVRendererOptions +} from "./UVRenderer.ts"; +export { + UVInputHandler, + type UVInputHandlerOptions, + type UVSnappingOptions +} from "./UVInputHandler.ts"; export type { Brush, DefaultViewport, - Vec2 + Vec2, + UVHandle, + UVHandleType } from "./types.ts"; export * from "./utils.ts"; diff --git a/packages/pixel-draw-renderer/src/types.ts b/packages/pixel-draw-renderer/src/types.ts index 5f0c3c2..16def28 100644 --- a/packages/pixel-draw-renderer/src/types.ts +++ b/packages/pixel-draw-renderer/src/types.ts @@ -3,7 +3,26 @@ export type Vec2 = { y: number; }; -export type Mode = "paint" | "move"; +export type Mode = + | "paint" + | "move" + | "uv"; + +export type UVHandleType = + | "corner-tl" + | "corner-tr" + | "corner-bl" + | "corner-br" + | "edge-t" + | "edge-b" + | "edge-l" + | "edge-r" + | "body"; + +export interface UVHandle { + readonly regionId: string; + readonly type: UVHandleType; +} export interface DefaultViewport { readonly zoom: number; diff --git a/packages/pixel-draw-renderer/test/UVHistory.spec.ts b/packages/pixel-draw-renderer/test/UVHistory.spec.ts new file mode 100644 index 0000000..a36db0f --- /dev/null +++ b/packages/pixel-draw-renderer/test/UVHistory.spec.ts @@ -0,0 +1,212 @@ +// Import Node.js Dependencies +import { describe, test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { UVHistory, type UVCommand } from "../src/UVHistory.ts"; + +function makeCounter(id: string = "r1"): { + value: number; + makeCommand(delta: number): UVCommand; +} { + const state = { value: 0 }; + + return { + get value() { + return state.value; + }, + makeCommand(delta: number): UVCommand { + return { + type: "move", + regionId: id, + execute() { + state.value += delta; + }, + undo() { + state.value -= delta; + } + }; + } + }; +} + +describe("UVHistory", () => { + let history: UVHistory; + + beforeEach(() => { + history = new UVHistory(); + }); + + describe("initial state", () => { + test("canUndo and canRedo are false when empty", () => { + assert.strictEqual(history.canUndo, false); + assert.strictEqual(history.canRedo, false); + }); + + test("undo() on empty stack does not throw", () => { + assert.doesNotThrow(() => history.undo()); + }); + + test("redo() on empty stack does not throw", () => { + assert.doesNotThrow(() => history.redo()); + }); + }); + + describe("push / undo / redo", () => { + test("push enables canUndo", () => { + const { makeCommand } = makeCounter(); + history.push(makeCommand(1)); + assert.strictEqual(history.canUndo, true); + }); + + test("undo calls command.undo and enables canRedo", () => { + const counter = makeCounter(); + // UVMap pattern: caller pre-applies the change, push just records it. + counter.makeCommand(5).execute(); + const cmd = counter.makeCommand(5); + history.push(cmd); + history.undo(); + assert.strictEqual(counter.value, 0, "undo should reverse the pre-applied change"); + assert.strictEqual(history.canRedo, true); + assert.strictEqual(history.canUndo, false); + }); + + test("redo calls command.execute", () => { + const counter = makeCounter(); + // Pre-apply then push (same pattern as UVMap transactional methods). + counter.makeCommand(3).execute(); + const cmd = counter.makeCommand(3); + history.push(cmd); + history.undo(); + history.redo(); + assert.strictEqual(counter.value, 3, "redo should re-apply the change"); + assert.strictEqual(history.canUndo, true); + assert.strictEqual(history.canRedo, false); + }); + + test("push after undo clears redo stack", () => { + const counter = makeCounter(); + history.push(counter.makeCommand(1)); + history.undo(); + history.push(counter.makeCommand(1)); + assert.strictEqual(history.canRedo, false, "redo stack should be cleared"); + }); + }); + + describe("tryMerge coalescing", () => { + test("consecutive merge-compatible commands are absorbed into one entry", () => { + // Build a command that has tryMerge + const counter = { value: 0 }; + + function makeMovCmd(delta: number): UVCommand & { _d: number; } { + let acc = delta; + + return { + type: "move", + regionId: "r1", + _d: delta, + execute() { + counter.value += acc; + }, + undo() { + counter.value -= acc; + }, + tryMerge(other) { + if (other.type !== "move" || other.regionId !== "r1") { + return false; + } + acc += (other as ReturnType)._d; + + return true; + } + }; + } + + history.push(makeMovCmd(1)); + history.push(makeMovCmd(2)); + history.push(makeMovCmd(3)); + + // All three should be coalesced into one stack entry + assert.strictEqual(history.canUndo, true); + history.undo(); + // After undo, canUndo should be false (only one entry existed) + assert.strictEqual(history.canUndo, false); + }); + + test("non-matching commands are NOT merged", () => { + makeCounter(); + + const cmd1: UVCommand = { + type: "move", + regionId: "r1", + execute() { + // No-op + }, + undo() { + // No-op + } + }; + const cmd2: UVCommand = { + type: "move", + regionId: "r2", + execute() { + // No-op + }, + undo() { + // No-op + } + }; + + history.push(cmd1); + history.push(cmd2); + + // Two separate entries → canUndo after one undo, still canUndo after another + history.undo(); + assert.strictEqual(history.canUndo, true, "should have second entry left"); + }); + }); + + describe("maxSize cap", () => { + test("stack is capped at maxSize", () => { + const smallHistory = new UVHistory({ maxSize: 3 }); + for (let i = 0; i < 10; i++) { + smallHistory.push({ + type: "move", + regionId: "r1", + execute() { + // No-op + }, + undo() { + // No-op + } + }); + } + // Undo 3 times should be possible, 4th should be a no-op + let undoCount = 0; + while (smallHistory.canUndo) { + smallHistory.undo(); + undoCount++; + } + assert.strictEqual(undoCount, 3); + }); + }); + + describe("clear", () => { + test("clear removes all undo and redo entries", () => { + history.push({ + type: "move", + regionId: "r1", + execute() { + // No-op + }, + undo() { + // No-op + } + }); + history.undo(); + history.clear(); + assert.strictEqual(history.canUndo, false); + assert.strictEqual(history.canRedo, false); + }); + }); +}); diff --git a/packages/pixel-draw-renderer/test/UVInputHandler.spec.ts b/packages/pixel-draw-renderer/test/UVInputHandler.spec.ts new file mode 100644 index 0000000..5b28a23 --- /dev/null +++ b/packages/pixel-draw-renderer/test/UVInputHandler.spec.ts @@ -0,0 +1,242 @@ +// Import Node.js Dependencies +import { describe, test, before } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import { Window } from "happy-dom"; + +// Import Internal Dependencies +import { UVInputHandler } from "../src/UVInputHandler.ts"; +import { UVMap } from "../src/UVMap.ts"; +import { UVRenderer } from "../src/UVRenderer.ts"; +import { Viewport } from "../src/Viewport.ts"; + +// CONSTANTS +const kEmulatedBrowserWindow = new Window(); +const kSvgNs = "http://www.w3.org/2000/svg"; + +before(() => { + globalThis.document = kEmulatedBrowserWindow.document as unknown as Document; +}); + +function makeSvg(): SVGElement { + return kEmulatedBrowserWindow.document.createElementNS( + kSvgNs, "svg" + ) as unknown as SVGElement; +} + +function makeViewport(zoom = 4): Viewport { + const vp = new Viewport({ textureSize: { x: 16, y: 16 }, zoom }); + vp.updateCanvasSize(200, 200); + vp.centerTexture(); + + return vp; +} + +function makeSetup(zoom = 4) { + const uvMap = new UVMap(); + const viewport = makeViewport(zoom); + const renderer = new UVRenderer({ + svg: makeSvg(), + uvMap, + viewport, + textureSize: { x: 16, y: 16 } + }); + const handler = new UVInputHandler({ + viewport, + uvMap, + uvRenderer: renderer, + textureSize: { x: 16, y: 16 }, + snapping: { pixelSnap: false, edgeSnap: false } + }); + + return { uvMap, viewport, renderer, handler }; +} + +describe("UVInputHandler", () => { + describe("snapUV", () => { + test("no snapping: returns u and v unchanged", () => { + const { handler } = makeSetup(); + const result = handler.snapUV(0.33, 0.66); + assert.ok(Math.abs(result.x - 0.33) < 1e-9); + assert.ok(Math.abs(result.y - 0.66) < 1e-9); + }); + + test("pixelSnap rounds to nearest pixel boundary", () => { + const { uvMap, viewport, renderer } = makeSetup(); + const snappingHandler = new UVInputHandler({ + viewport, + uvMap, + uvRenderer: renderer, + textureSize: { x: 4, y: 4 }, + snapping: { pixelSnap: true, edgeSnap: false } + }); + // 0.33 * 4 = 1.32 → round to 1 → 1/4 = 0.25 + const result = snappingHandler.snapUV(0.33, 0.66); + assert.ok(Math.abs(result.x - 0.25) < 1e-9); + // 0.66 * 4 = 2.64 → round to 3 → 3/4 = 0.75 + assert.ok(Math.abs(result.y - 0.75) < 1e-9); + snappingHandler.destroy(); + }); + + test("edgeSnap snaps to a nearby region edge", () => { + const { uvMap, viewport, renderer } = makeSetup(); + uvMap.add({ label: "A", u: 0.5, v: 0.5, width: 0.25, height: 0.25, color: "#f00" }); + + const snappingHandler = new UVInputHandler({ + viewport, + uvMap, + uvRenderer: renderer, + textureSize: { x: 16, y: 16 }, + snapping: { pixelSnap: false, edgeSnap: true, edgeSnapThreshold: 0.05 } + }); + // 0.51 is within 0.05 of 0.5 → should snap to 0.5 + const result = snappingHandler.snapUV(0.51, 0.51); + assert.ok(Math.abs(result.x - 0.5) < 1e-9, `expected 0.5 got ${result.x}`); + snappingHandler.destroy(); + }); + }); + + describe("hitTestHandle", () => { + test("returns null when no region is selected", () => { + const { handler } = makeSetup(); + assert.strictEqual(handler.hitTestHandle(0, 0), null); + }); + + test("returns null when selected region has no handle at given canvas position", () => { + const { uvMap, handler } = makeSetup(); + const region = uvMap.add({ label: "A", u: 0, v: 0, width: 0.5, height: 0.5, color: "#f00" }); + uvMap.select(region.id); + + // The handle for corner-br is at uvToSvg(0.5, 0.5) + // We test at a position far away from all handles + assert.strictEqual(handler.hitTestHandle(0, 0), null); + }); + + test("returns handle when mouse is near corner-tl of selected region", () => { + const { uvMap, handler, renderer } = makeSetup(10); + // zoom=10, camX=camY=centerTexture offset + // Add a region at u=0, v=0 + const region = uvMap.add({ + label: "A", u: 0, v: 0, width: 0.5, height: 0.5, color: "#f00" + }); + uvMap.select(region.id); + + // corner-tl is at uvToSvg(0, 0) = { x: camX + 0, y: camY + 0 } + const svgPos = renderer.uvToSvg(0, 0); + const handle = handler.hitTestHandle(svgPos.x, svgPos.y); + assert.ok(handle !== null, "Should detect corner-tl handle"); + assert.strictEqual(handle!.type, "corner-tl"); + assert.strictEqual(handle!.regionId, region.id); + }); + }); + + describe("hitTestRegion", () => { + test("returns null when no regions exist", () => { + const { handler, renderer } = makeSetup(4); + // Convert the center to canvas space and test + const { x: cx, y: cy } = renderer.uvToSvg(0.5, 0.5); + assert.strictEqual(handler.hitTestRegion(cx, cy), null); + }); + + test("returns region id when cursor is inside a region", () => { + const { uvMap, handler, renderer } = makeSetup(4); + const region = uvMap.add({ + label: "Face", u: 0.1, v: 0.1, width: 0.5, height: 0.5, color: "#f00" + }); + + // Center of region at u=0.35, v=0.35 + const svgPos = renderer.uvToSvg(0.35, 0.35); + const found = handler.hitTestRegion(svgPos.x, svgPos.y); + assert.strictEqual(found, region.id); + }); + + test("returns null when cursor is outside all regions", () => { + const { uvMap, handler, renderer } = makeSetup(4); + uvMap.add({ label: "Face", u: 0.1, v: 0.1, width: 0.2, height: 0.2, color: "#f00" }); + + // Outside the region + const svgPos = renderer.uvToSvg(0.9, 0.9); + assert.strictEqual(handler.hitTestRegion(svgPos.x, svgPos.y), null); + }); + }); + + describe("state machine — creating", () => { + test("drag on empty space then mouseup creates a region", () => { + const { uvMap, handler, renderer } = makeSetup(4); + + const start = renderer.uvToSvg(0.1, 0.1); + const end = renderer.uvToSvg(0.4, 0.4); + + handler.onMouseDown(start.x, start.y, 0); + handler.onMouseMove(end.x, end.y); + handler.onMouseUp(); + + assert.strictEqual(uvMap.size, 1); + }); + + test("tiny drag (smaller than minSize) does not create a region", () => { + const { uvMap, handler, renderer } = makeSetup(4); + + const start = renderer.uvToSvg(0.1, 0.1); + // Only 0.001 units wide — below kMinRegionSize (0.01) + const end = renderer.uvToSvg(0.1005, 0.1005); + + handler.onMouseDown(start.x, start.y, 0); + handler.onMouseMove(end.x, end.y); + handler.onMouseUp(); + + assert.strictEqual(uvMap.size, 0, "region below minSize should not be created"); + }); + }); + + describe("state machine — moving", () => { + test("mousedown on region starts moving, mousemove changes position", () => { + const { uvMap, handler, renderer } = makeSetup(4); + const region = uvMap.add({ + label: "A", u: 0.2, v: 0.2, width: 0.3, height: 0.3, color: "#f00" + }); + uvMap.select(region.id); + + const initialU = region.u; + const initialV = region.v; + + // Click on center of region + const center = renderer.uvToSvg(0.35, 0.35); + const target = renderer.uvToSvg(0.45, 0.45); + + handler.onMouseDown(center.x, center.y, 0); + handler.onMouseMove(target.x, target.y); + handler.onMouseUp(); + + assert.ok(region.u !== initialU || region.v !== initialV, "region should have moved"); + }); + }); + + describe("onDeleteKey", () => { + test("deletes selected region", () => { + const { uvMap, handler } = makeSetup(); + const region = uvMap.add({ + label: "A", u: 0, v: 0, width: 0.25, height: 0.25, color: "#f00" + }); + uvMap.select(region.id); + handler.onDeleteKey(); + assert.strictEqual(uvMap.size, 0); + }); + + test("does nothing when no region is selected", () => { + const { uvMap, handler } = makeSetup(); + uvMap.add({ label: "A", u: 0, v: 0, width: 0.25, height: 0.25, color: "#f00" }); + // No selection + assert.doesNotThrow(() => handler.onDeleteKey()); + assert.strictEqual(uvMap.size, 1, "non-selected region should remain"); + }); + }); + + describe("destroy", () => { + test("destroy does not throw", () => { + const { handler } = makeSetup(); + assert.doesNotThrow(() => handler.destroy()); + }); + }); +}); diff --git a/packages/pixel-draw-renderer/test/UVMap.spec.ts b/packages/pixel-draw-renderer/test/UVMap.spec.ts new file mode 100644 index 0000000..dc6aa66 --- /dev/null +++ b/packages/pixel-draw-renderer/test/UVMap.spec.ts @@ -0,0 +1,266 @@ +// Import Node.js Dependencies +import { describe, test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { + UVMap, + type UVMapChangedDetail +} from "../src/UVMap.ts"; + +function collectEvents(uvMap: UVMap): UVMapChangedDetail[] { + const events: UVMapChangedDetail[] = []; + uvMap.addEventListener("changed", (e) => { + events.push((e as CustomEvent).detail); + }); + + return events; +} + +function makeData() { + return { + label: "Top", + u: 0.0, + v: 0.0, + width: 0.25, + height: 0.25, + color: "#f00" + }; +} + +describe("UVMap", () => { + let uvMap: UVMap; + + beforeEach(() => { + uvMap = new UVMap(); + }); + + describe("add / remove / get / has / size / iterator", () => { + test("add returns a region with the data", () => { + const r = uvMap.add(makeData()); + assert.ok(r.id.length > 0); + assert.strictEqual(r.label, "Top"); + }); + + test("add with explicit id uses that id", () => { + const r = uvMap.add({ ...makeData(), id: "myId" }); + assert.strictEqual(r.id, "myId"); + }); + + test("get retrieves the region by id", () => { + const r = uvMap.add(makeData()); + assert.strictEqual(uvMap.get(r.id), r); + }); + + test("has returns false for unknown id", () => { + assert.strictEqual(uvMap.has("nope"), false); + }); + + test("remove deletes the region", () => { + const r = uvMap.add(makeData()); + uvMap.remove(r.id); + assert.strictEqual(uvMap.has(r.id), false); + }); + + test("size reflects number of regions", () => { + assert.strictEqual(uvMap.size, 0); + uvMap.add(makeData()); + uvMap.add(makeData()); + assert.strictEqual(uvMap.size, 2); + }); + + test("[Symbol.iterator] yields all regions", () => { + uvMap.add(makeData()); + uvMap.add(makeData()); + const ids = [...uvMap].map((r) => r.id); + assert.strictEqual(ids.length, 2); + }); + }); + + describe("select", () => { + test("select updates selectedId and fires 'select' event", () => { + const r = uvMap.add(makeData()); + const events = collectEvents(uvMap); + uvMap.select(r.id); + assert.strictEqual(uvMap.selectedId, r.id); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].type, "select"); + assert.strictEqual(events[0].regionId, r.id); + }); + + test("select(null) clears selection", () => { + const r = uvMap.add(makeData()); + uvMap.select(r.id); + uvMap.select(null); + assert.strictEqual(uvMap.selectedId, null); + }); + }); + + describe("createRegion", () => { + test("adds the region and fires 'add' event", () => { + const events = collectEvents(uvMap); + uvMap.createRegion(makeData()); + assert.strictEqual(uvMap.size, 1); + assert.strictEqual(events[0].type, "add"); + }); + + test("undo removes the region", () => { + uvMap.createRegion(makeData()); + uvMap.undo(); + assert.strictEqual(uvMap.size, 0); + }); + + test("redo re-adds the region", () => { + uvMap.createRegion(makeData()); + uvMap.undo(); + uvMap.redo(); + assert.strictEqual(uvMap.size, 1); + }); + + test("canUndo is true after createRegion", () => { + uvMap.createRegion(makeData()); + assert.strictEqual(uvMap.canUndo, true); + }); + }); + + describe("deleteRegion", () => { + test("removes the region and fires 'remove' event", () => { + const r = uvMap.add(makeData()); + const events = collectEvents(uvMap); + uvMap.deleteRegion(r.id); + assert.strictEqual(uvMap.size, 0); + assert.strictEqual(events[0].type, "remove"); + }); + + test("deleteRegion on unknown id does nothing", () => { + uvMap.deleteRegion("nonexistent"); + assert.strictEqual(uvMap.size, 0); + }); + + test("undo restores the region", () => { + const r = uvMap.add({ ...makeData(), id: "fixed" }); + uvMap.deleteRegion(r.id); + uvMap.undo(); + assert.strictEqual(uvMap.size, 1); + assert.ok(uvMap.has("fixed")); + }); + }); + + describe("moveRegion", () => { + test("moves the region by the given delta", () => { + const r = uvMap.add({ ...makeData(), u: 0.1, v: 0.1 }); + uvMap.moveRegion(r.id, 0.1, 0.05); + assert.ok(Math.abs(r.u - 0.2) < 1e-10); + assert.ok(Math.abs(r.v - 0.15) < 1e-10); + }); + + test("fires 'move' changed event", () => { + const r = uvMap.add(makeData()); + const events = collectEvents(uvMap); + uvMap.moveRegion(r.id, 0.1, 0); + assert.strictEqual(events[0].type, "move"); + }); + + test("undo reverses the move", () => { + const r = uvMap.add({ ...makeData(), u: 0.1, v: 0.1 }); + uvMap.moveRegion(r.id, 0.2, 0.1); + uvMap.undo(); + assert.ok(Math.abs(r.u - 0.1) < 1e-10); + assert.ok(Math.abs(r.v - 0.1) < 1e-10); + }); + + test("redo re-applies the move", () => { + const r = uvMap.add({ ...makeData(), u: 0.1, v: 0.1 }); + uvMap.moveRegion(r.id, 0.2, 0.1); + uvMap.undo(); + uvMap.redo(); + assert.ok(Math.abs(r.u - 0.3) < 1e-10); + assert.ok(Math.abs(r.v - 0.2) < 1e-10); + }); + + test("clamps to [0, 1]", () => { + const r = uvMap.add({ ...makeData(), u: 0.9, v: 0.9, width: 0.05, height: 0.05 }); + uvMap.moveRegion(r.id, 0.5, 0.5); + assert.ok(r.u <= 1); + assert.ok(r.v <= 1); + }); + + test("moveRegion on unknown id does nothing", () => { + assert.doesNotThrow(() => uvMap.moveRegion("nope", 0.1, 0.1)); + }); + }); + + describe("resizeRegion", () => { + test("edge-r increases width", () => { + const r = uvMap.add({ ...makeData(), u: 0, v: 0, width: 0.25, height: 0.25 }); + uvMap.resizeRegion(r.id, "edge-r", { du: 0.1, dv: 0 }); + assert.ok(Math.abs(r.width - 0.35) < 1e-10); + }); + + test("undo restores original dimensions", () => { + const r = uvMap.add({ ...makeData(), u: 0, v: 0, width: 0.25, height: 0.25 }); + uvMap.resizeRegion(r.id, "edge-r", { du: 0.1, dv: 0 }); + uvMap.undo(); + assert.ok(Math.abs(r.width - 0.25) < 1e-10); + }); + + test("redo re-applies the resize", () => { + const r = uvMap.add({ ...makeData(), u: 0, v: 0, width: 0.25, height: 0.25 }); + uvMap.resizeRegion(r.id, "edge-r", { du: 0.1, dv: 0 }); + uvMap.undo(); + uvMap.redo(); + assert.ok(Math.abs(r.width - 0.35) < 1e-10); + }); + }); + + describe("setLabel", () => { + test("updates the region label", () => { + const r = uvMap.add(makeData()); + uvMap.setLabel(r.id, "New Label"); + assert.strictEqual(r.label, "New Label"); + }); + + test("fires 'label' changed event", () => { + const r = uvMap.add(makeData()); + const events = collectEvents(uvMap); + uvMap.setLabel(r.id, "X"); + assert.strictEqual(events[0].type, "label"); + }); + + test("undo restores old label", () => { + const r = uvMap.add({ ...makeData(), label: "Old" }); + uvMap.setLabel(r.id, "New"); + uvMap.undo(); + assert.strictEqual(r.label, "Old"); + }); + }); + + describe("toJSON / fromJSON", () => { + test("toJSON returns array of region data", () => { + uvMap.add({ ...makeData(), id: "a" }); + uvMap.add({ ...makeData(), id: "b" }); + const json = uvMap.toJSON(); + assert.strictEqual(json.length, 2); + assert.ok(json.find((d) => d.id === "a")); + assert.ok(json.find((d) => d.id === "b")); + }); + + test("fromJSON restores all regions", () => { + uvMap.add({ ...makeData(), id: "a", label: "A" }); + uvMap.add({ ...makeData(), id: "b", label: "B" }); + const json = uvMap.toJSON(); + + const restored = UVMap.fromJSON(json); + assert.strictEqual(restored.size, 2); + assert.strictEqual(restored.get("a")?.label, "A"); + assert.strictEqual(restored.get("b")?.label, "B"); + }); + + test("fromJSON → toJSON roundtrip", () => { + uvMap.add({ ...makeData(), id: "a", u: 0.25, v: 0.5 }); + const json = uvMap.toJSON(); + const restored = UVMap.fromJSON(json); + assert.deepStrictEqual(restored.toJSON(), json); + }); + }); +}); diff --git a/packages/pixel-draw-renderer/test/UVRegion.spec.ts b/packages/pixel-draw-renderer/test/UVRegion.spec.ts new file mode 100644 index 0000000..b9d655b --- /dev/null +++ b/packages/pixel-draw-renderer/test/UVRegion.spec.ts @@ -0,0 +1,132 @@ +// Import Node.js Dependencies +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { UVRegion } from "../src/UVRegion.ts"; + +function makeRegion(overrides?: Partial[0]>): UVRegion { + return new UVRegion({ + id: "r1", + label: "Top", + u: 0.1, + v: 0.2, + width: 0.5, + height: 0.3, + color: "#f00", + ...overrides + }); +} + +describe("UVRegion", () => { + describe("constructor", () => { + test("stores all fields", () => { + const r = makeRegion(); + assert.strictEqual(r.id, "r1"); + assert.strictEqual(r.label, "Top"); + assert.strictEqual(r.u, 0.1); + assert.strictEqual(r.v, 0.2); + assert.strictEqual(r.width, 0.5); + assert.strictEqual(r.height, 0.3); + assert.strictEqual(r.color, "#f00"); + }); + }); + + describe("clamp", () => { + test("clamps u and v to [0, 1]", () => { + const r = makeRegion({ u: -0.5, v: 1.5, width: 0.1, height: 0.1 }); + r.clamp(); + assert.strictEqual(r.u, 0); + assert.strictEqual(r.v, 1); + }); + + test("clamps width and height so region stays in bounds", () => { + const r = makeRegion({ u: 0.8, v: 0.8, width: 0.5, height: 0.5 }); + r.clamp(); + assert.ok(r.u + r.width <= 1, "u + width should be <= 1"); + assert.ok(r.v + r.height <= 1, "v + height should be <= 1"); + }); + + test("does not modify already-valid region", () => { + const r = makeRegion({ u: 0, v: 0, width: 0.5, height: 0.5 }); + r.clamp(); + assert.strictEqual(r.u, 0); + assert.strictEqual(r.v, 0); + assert.strictEqual(r.width, 0.5); + assert.strictEqual(r.height, 0.5); + }); + + test("clamps width to 0 when u is already 1", () => { + const r = makeRegion({ u: 1, v: 0, width: 0.3, height: 0.1 }); + r.clamp(); + assert.strictEqual(r.width, 0); + }); + }); + + describe("snapToPixel", () => { + test("snaps u and v to nearest pixel boundary", () => { + const r = makeRegion({ u: 0.33, v: 0.66, width: 0.25, height: 0.25 }); + r.snapToPixel(4, 4); + // 0.33 * 4 = 1.32 → rounds to 1 → 1/4 = 0.25 + assert.strictEqual(r.u, 0.25); + // 0.66 * 4 = 2.64 → rounds to 3 → 3/4 = 0.75 + assert.strictEqual(r.v, 0.75); + }); + + test("snaps aligned value to itself", () => { + const r = makeRegion({ u: 0.25, v: 0.5, width: 0.25, height: 0.25 }); + r.snapToPixel(4, 4); + assert.strictEqual(r.u, 0.25); + assert.strictEqual(r.v, 0.5); + }); + + test("snaps width and height to pixel boundaries", () => { + const r = makeRegion({ u: 0, v: 0, width: 0.37, height: 0.63 }); + r.snapToPixel(4, 4); + // 0.37 * 4 = 1.48 → 1 → 0.25 + assert.strictEqual(r.width, 0.25); + // 0.63 * 4 = 2.52 → 3 → 0.75 + assert.strictEqual(r.height, 0.75); + }); + }); + + describe("toData / fromData", () => { + test("toData returns an object matching the region state", () => { + const r = makeRegion(); + const data = r.toData(); + assert.deepStrictEqual(data, { + id: "r1", + label: "Top", + u: 0.1, + v: 0.2, + width: 0.5, + height: 0.3, + color: "#f00" + }); + }); + + test("toData returns a separate object (not a reference)", () => { + const r = makeRegion(); + const data = r.toData(); + r.u = 0.9; + assert.strictEqual(data.u, 0.1, "toData snapshot should not reflect subsequent mutation"); + }); + + test("fromData restores all fields", () => { + const r = makeRegion(); + r.fromData({ id: "r2", label: "Bottom", u: 0.5, v: 0.5, width: 0.1, height: 0.1, color: "#0f0" }); + assert.strictEqual(r.id, "r2"); + assert.strictEqual(r.label, "Bottom"); + assert.strictEqual(r.u, 0.5); + assert.strictEqual(r.color, "#0f0"); + }); + + test("fromData / toData roundtrip", () => { + const r = makeRegion(); + const original = r.toData(); + r.u = 0.99; + r.fromData(original); + assert.deepStrictEqual(r.toData(), original); + }); + }); +}); diff --git a/packages/pixel-draw-renderer/test/UVRenderer.spec.ts b/packages/pixel-draw-renderer/test/UVRenderer.spec.ts new file mode 100644 index 0000000..5a1168e --- /dev/null +++ b/packages/pixel-draw-renderer/test/UVRenderer.spec.ts @@ -0,0 +1,183 @@ +// Import Node.js Dependencies +import { describe, test, before, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import { Window } from "happy-dom"; + +// Import Internal Dependencies +import { UVRenderer } from "../src/UVRenderer.ts"; +import { UVMap } from "../src/UVMap.ts"; +import type { DefaultViewport } from "../src/types.ts"; + +// CONSTANTS +const kEmulatedBrowserWindow = new Window(); +const kSvgNs = "http://www.w3.org/2000/svg"; + +before(() => { + globalThis.document = kEmulatedBrowserWindow.document as unknown as Document; +}); + +function makeViewport(zoom = 4, camX = 0, camY = 0): DefaultViewport { + return { zoom, camera: { x: camX, y: camY } }; +} + +function makeSvg(): SVGElement { + return kEmulatedBrowserWindow.document.createElementNS( + kSvgNs, "svg" + ) as unknown as SVGElement; +} + +function makeRenderer(uvMap?: UVMap, viewport?: DefaultViewport): UVRenderer { + return new UVRenderer({ + svg: makeSvg(), + uvMap: uvMap ?? new UVMap(), + viewport: viewport ?? makeViewport(), + textureSize: { x: 16, y: 16 } + }); +} + +describe("UVRenderer", () => { + let uvMap: UVMap; + let renderer: UVRenderer; + + beforeEach(() => { + uvMap = new UVMap(); + renderer = makeRenderer(uvMap); + }); + + describe("uvToSvg", () => { + test("u=0,v=0 at default viewport maps to camera origin", () => { + const r = makeRenderer(new UVMap(), makeViewport(4, 10, 20)); + const pos = r.uvToSvg(0, 0); + assert.strictEqual(pos.x, 10); + assert.strictEqual(pos.y, 20); + }); + + test("u=1,v=1 maps to camera + textureSize * zoom", () => { + const r = makeRenderer(new UVMap(), makeViewport(4, 0, 0)); + const pos = r.uvToSvg(1, 1); + // textureSize={16,16}, zoom=4 → 16*4=64 + assert.strictEqual(pos.x, 64); + assert.strictEqual(pos.y, 64); + }); + + test("u=0.5 maps to half-way across the texture", () => { + const r = makeRenderer(new UVMap(), makeViewport(4, 0, 0)); + const pos = r.uvToSvg(0.5, 0); + assert.strictEqual(pos.x, 32); + }); + }); + + describe("svgToUV", () => { + test("roundtrips with uvToSvg", () => { + const r = makeRenderer(new UVMap(), makeViewport(4, 8, 12)); + const uv = { u: 0.25, v: 0.75 }; + const svgPos = r.uvToSvg(uv.u, uv.v); + const back = r.svgToUV(svgPos.x, svgPos.y); + assert.ok(Math.abs(back.x - uv.u) < 1e-9); + assert.ok(Math.abs(back.y - uv.v) < 1e-9); + }); + + test("returns 0,0 for camera position when zoom > 0", () => { + const r = makeRenderer(new UVMap(), makeViewport(4, 20, 30)); + const uv = r.svgToUV(20, 30); + assert.ok(Math.abs(uv.x) < 1e-9); + assert.ok(Math.abs(uv.y) < 1e-9); + }); + }); + + describe("region SVG elements lifecycle", () => { + test("createRegion adds an SVG group", () => { + const svg = makeSvg(); + kEmulatedBrowserWindow.document.body.appendChild(svg as any); + const r = new UVRenderer({ + svg, + uvMap, + viewport: makeViewport(), + textureSize: { x: 16, y: 16 } + }); + + uvMap.createRegion({ + label: "Face", + u: 0, v: 0, width: 0.25, height: 0.25, color: "#f00" + }); + + const group = svg.querySelector(`[id^="uv-region-"]`); + assert.ok(group !== null, "SVG group should be added for the region"); + r.destroy(); + }); + + test("deleteRegion removes the SVG group", () => { + const svg = makeSvg(); + kEmulatedBrowserWindow.document.body.appendChild(svg as any); + const r = new UVRenderer({ + svg, + uvMap, + viewport: makeViewport(), + textureSize: { x: 16, y: 16 } + }); + + const region = uvMap.createRegion({ + label: "Face", + u: 0, v: 0, width: 0.25, height: 0.25, color: "#f00" + }); + + uvMap.deleteRegion(region.id); + const group = svg.querySelector(`#uv-region-${region.id}`); + assert.strictEqual(group, null, "SVG group should be removed after delete"); + r.destroy(); + }); + }); + + describe("setDragPreview", () => { + test("setDragPreview(null) does not throw", () => { + assert.doesNotThrow(() => renderer.setDragPreview(null)); + }); + + test("setDragPreview with data does not throw", () => { + assert.doesNotThrow(() => renderer.setDragPreview({ + id: "__preview__", + label: "", + u: 0.1, + v: 0.1, + width: 0.2, + height: 0.2, + color: "#4af" + })); + }); + }); + + describe("destroy", () => { + test("destroy removes overlay groups from SVG", () => { + const svg = makeSvg(); + kEmulatedBrowserWindow.document.body.appendChild(svg as any); + const r = new UVRenderer({ + svg, + uvMap: new UVMap(), + viewport: makeViewport(), + textureSize: { x: 16, y: 16 } + }); + + r.destroy(); + assert.strictEqual( + svg.querySelector("#uv-overlay"), + null, + "overlay group should be removed after destroy" + ); + }); + + test("destroy can be called twice without throwing", () => { + const svg = makeSvg(); + kEmulatedBrowserWindow.document.body.appendChild(svg as any); + const r = new UVRenderer({ + svg, + uvMap: new UVMap(), + viewport: makeViewport(), + textureSize: { x: 16, y: 16 } + }); + r.destroy(); + assert.doesNotThrow(() => r.destroy()); + }); + }); +});