From fd4ff0472e7f6e6b0be74ac1e29fb14f6d20d2aa Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:27:30 +0200 Subject: [PATCH 1/9] feat: add backward-compatible perspective settings --- src/types/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index 72234e2..452d7eb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,23 @@ export type Anchor = | "bottom-left" | "bottom-right"; +export interface PerspectivePoint { + x: number; + y: number; +} + +export interface PerspectiveCorners { + topLeft: PerspectivePoint; + topRight: PerspectivePoint; + bottomRight: PerspectivePoint; + bottomLeft: PerspectivePoint; +} + +export interface PerspectiveSettings { + enabled: boolean; + corners: PerspectiveCorners; +} + export type ExportFormat = "png" | "jpg" | "webp"; export type CropPreset = "none" | "square" | "portrait45" | "pinterest23" | "story916"; export type WatermarkPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left" | "center"; @@ -46,6 +63,7 @@ export interface MockupSettings { opacity: number; fitMode: FitMode; anchor: Anchor; + perspective?: PerspectiveSettings; } export interface SavedPreset { From 1cd7a9de41368dc0b3e5718701e336825345252c Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:28:00 +0200 Subject: [PATCH 2/9] feat: add four-corner perspective math and renderer --- src/lib/perspective.ts | 203 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 src/lib/perspective.ts diff --git a/src/lib/perspective.ts b/src/lib/perspective.ts new file mode 100644 index 0000000..13c9644 --- /dev/null +++ b/src/lib/perspective.ts @@ -0,0 +1,203 @@ +import type { PerspectiveCorners, PerspectivePoint } from "../types"; +import type { PlacementMetrics } from "./renderPlacement"; + +export type PerspectiveCornerName = keyof PerspectiveCorners; + +interface Homography { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; + g: number; + h: number; +} + +function cross(a: PerspectivePoint, b: PerspectivePoint, c: PerspectivePoint): number { + return (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); +} + +export function isValidPerspectiveCorners(corners: PerspectiveCorners): boolean { + const points = [corners.topLeft, corners.topRight, corners.bottomRight, corners.bottomLeft]; + if (points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) return false; + + const signedArea = points.reduce((sum, point, index) => { + const next = points[(index + 1) % points.length]; + return sum + point.x * next.y - next.x * point.y; + }, 0) / 2; + if (Math.abs(signedArea) < 1) return false; + + const turns = [ + cross(points[0], points[1], points[2]), + cross(points[1], points[2], points[3]), + cross(points[2], points[3], points[0]), + cross(points[3], points[0], points[1]), + ]; + const positive = turns.every((value) => value > 0.01); + const negative = turns.every((value) => value < -0.01); + return positive || negative; +} + +export function clampPerspectivePoint(point: PerspectivePoint): PerspectivePoint { + return { + x: Math.max(0, Math.min(100, point.x)), + y: Math.max(0, Math.min(100, point.y)), + }; +} + +export function perspectiveCornersFromMetrics(metrics: PlacementMetrics): PerspectiveCorners { + const left = metrics.targetLeft; + const top = metrics.targetTop; + const right = metrics.targetLeft + metrics.targetWidth; + const bottom = metrics.targetTop + metrics.targetHeight; + return { + topLeft: { x: left, y: top }, + topRight: { x: right, y: top }, + bottomRight: { x: right, y: bottom }, + bottomLeft: { x: left, y: bottom }, + }; +} + +export function homographyFromUnitSquare(corners: PerspectiveCorners): Homography | null { + if (!isValidPerspectiveCorners(corners)) return null; + + const x0 = corners.topLeft.x; + const y0 = corners.topLeft.y; + const x1 = corners.topRight.x; + const y1 = corners.topRight.y; + const x2 = corners.bottomRight.x; + const y2 = corners.bottomRight.y; + const x3 = corners.bottomLeft.x; + const y3 = corners.bottomLeft.y; + + const dx1 = x1 - x2; + const dx2 = x3 - x2; + const dx3 = x0 - x1 + x2 - x3; + const dy1 = y1 - y2; + const dy2 = y3 - y2; + const dy3 = y0 - y1 + y2 - y3; + + if (Math.abs(dx3) < 1e-9 && Math.abs(dy3) < 1e-9) { + return { + a: x1 - x0, + b: x3 - x0, + c: x0, + d: y1 - y0, + e: y3 - y0, + f: y0, + g: 0, + h: 0, + }; + } + + const denominator = dx1 * dy2 - dx2 * dy1; + if (Math.abs(denominator) < 1e-9) return null; + + const g = (dx3 * dy2 - dx2 * dy3) / denominator; + const h = (dx1 * dy3 - dx3 * dy1) / denominator; + + return { + a: x1 - x0 + g * x1, + b: x3 - x0 + h * x3, + c: x0, + d: y1 - y0 + g * y1, + e: y3 - y0 + h * y3, + f: y0, + g, + h, + }; +} + +export function mapHomographyPoint(matrix: Homography, u: number, v: number): PerspectivePoint { + const denominator = matrix.g * u + matrix.h * v + 1; + return { + x: (matrix.a * u + matrix.b * v + matrix.c) / denominator, + y: (matrix.d * u + matrix.e * v + matrix.f) / denominator, + }; +} + +function drawImageTriangle( + ctx: CanvasRenderingContext2D, + image: CanvasImageSource, + source: [PerspectivePoint, PerspectivePoint, PerspectivePoint], + destination: [PerspectivePoint, PerspectivePoint, PerspectivePoint], +): void { + const [s0, s1, s2] = source; + const [d0, d1, d2] = destination; + const denominator = s0.x * (s1.y - s2.y) + s1.x * (s2.y - s0.y) + s2.x * (s0.y - s1.y); + if (Math.abs(denominator) < 1e-9) return; + + const a = (d0.x * (s1.y - s2.y) + d1.x * (s2.y - s0.y) + d2.x * (s0.y - s1.y)) / denominator; + const c = (d0.x * (s2.x - s1.x) + d1.x * (s0.x - s2.x) + d2.x * (s1.x - s0.x)) / denominator; + const e = ( + d0.x * (s1.x * s2.y - s2.x * s1.y) + + d1.x * (s2.x * s0.y - s0.x * s2.y) + + d2.x * (s0.x * s1.y - s1.x * s0.y) + ) / denominator; + const b = (d0.y * (s1.y - s2.y) + d1.y * (s2.y - s0.y) + d2.y * (s0.y - s1.y)) / denominator; + const d = (d0.y * (s2.x - s1.x) + d1.y * (s0.x - s2.x) + d2.y * (s1.x - s0.x)) / denominator; + const f = ( + d0.y * (s1.x * s2.y - s2.x * s1.y) + + d1.y * (s2.x * s0.y - s0.x * s2.y) + + d2.y * (s0.x * s1.y - s1.x * s0.y) + ) / denominator; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(d0.x, d0.y); + ctx.lineTo(d1.x, d1.y); + ctx.lineTo(d2.x, d2.y); + ctx.closePath(); + ctx.clip(); + ctx.transform(a, b, c, d, e, f); + ctx.drawImage(image, 0, 0); + ctx.restore(); +} + +export function drawPerspectiveImage( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + cornersPercent: PerspectiveCorners, + canvasWidth: number, + canvasHeight: number, + subdivisions = 10, +): boolean { + if (!isValidPerspectiveCorners(cornersPercent)) return false; + + const corners: PerspectiveCorners = { + topLeft: { x: (cornersPercent.topLeft.x / 100) * canvasWidth, y: (cornersPercent.topLeft.y / 100) * canvasHeight }, + topRight: { x: (cornersPercent.topRight.x / 100) * canvasWidth, y: (cornersPercent.topRight.y / 100) * canvasHeight }, + bottomRight: { x: (cornersPercent.bottomRight.x / 100) * canvasWidth, y: (cornersPercent.bottomRight.y / 100) * canvasHeight }, + bottomLeft: { x: (cornersPercent.bottomLeft.x / 100) * canvasWidth, y: (cornersPercent.bottomLeft.y / 100) * canvasHeight }, + }; + const matrix = homographyFromUnitSquare(corners); + if (!matrix) return false; + + const sourceWidth = image.naturalWidth || image.width || 1; + const sourceHeight = image.naturalHeight || image.height || 1; + const steps = Math.max(2, Math.min(24, Math.round(subdivisions))); + + for (let row = 0; row < steps; row += 1) { + const v0 = row / steps; + const v1 = (row + 1) / steps; + for (let column = 0; column < steps; column += 1) { + const u0 = column / steps; + const u1 = (column + 1) / steps; + + const s00 = { x: u0 * sourceWidth, y: v0 * sourceHeight }; + const s10 = { x: u1 * sourceWidth, y: v0 * sourceHeight }; + const s11 = { x: u1 * sourceWidth, y: v1 * sourceHeight }; + const s01 = { x: u0 * sourceWidth, y: v1 * sourceHeight }; + const d00 = mapHomographyPoint(matrix, u0, v0); + const d10 = mapHomographyPoint(matrix, u1, v0); + const d11 = mapHomographyPoint(matrix, u1, v1); + const d01 = mapHomographyPoint(matrix, u0, v1); + + drawImageTriangle(ctx, image, [s00, s10, s11], [d00, d10, d11]); + drawImageTriangle(ctx, image, [s00, s11, s01], [d00, d11, d01]); + } + } + + return true; +} From c864137ba1f8f2ea06fe33d2fc1601f0bc4ffa3f Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:28:15 +0200 Subject: [PATCH 3/9] test: cover perspective mapping and quad validation --- src/lib/perspective.test.ts | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/lib/perspective.test.ts diff --git a/src/lib/perspective.test.ts b/src/lib/perspective.test.ts new file mode 100644 index 0000000..f3c8b2c --- /dev/null +++ b/src/lib/perspective.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { PerspectiveCorners } from "../types"; +import { + homographyFromUnitSquare, + isValidPerspectiveCorners, + mapHomographyPoint, +} from "./perspective"; + +const corners: PerspectiveCorners = { + topLeft: { x: 10, y: 20 }, + topRight: { x: 80, y: 10 }, + bottomRight: { x: 90, y: 85 }, + bottomLeft: { x: 20, y: 90 }, +}; + +describe("four-corner perspective", () => { + it("maps all unit-square corners onto the requested quadrilateral", () => { + const matrix = homographyFromUnitSquare(corners); + expect(matrix).not.toBeNull(); + if (!matrix) return; + + expect(mapHomographyPoint(matrix, 0, 0)).toEqual(corners.topLeft); + expect(mapHomographyPoint(matrix, 1, 0).x).toBeCloseTo(corners.topRight.x, 8); + expect(mapHomographyPoint(matrix, 1, 0).y).toBeCloseTo(corners.topRight.y, 8); + expect(mapHomographyPoint(matrix, 1, 1).x).toBeCloseTo(corners.bottomRight.x, 8); + expect(mapHomographyPoint(matrix, 1, 1).y).toBeCloseTo(corners.bottomRight.y, 8); + expect(mapHomographyPoint(matrix, 0, 1).x).toBeCloseTo(corners.bottomLeft.x, 8); + expect(mapHomographyPoint(matrix, 0, 1).y).toBeCloseTo(corners.bottomLeft.y, 8); + }); + + it("rejects self-intersecting quadrilaterals", () => { + expect(isValidPerspectiveCorners({ + topLeft: { x: 10, y: 10 }, + topRight: { x: 90, y: 90 }, + bottomRight: { x: 90, y: 10 }, + bottomLeft: { x: 10, y: 90 }, + })).toBe(false); + }); + + it("rejects near-zero quadrilaterals", () => { + expect(isValidPerspectiveCorners({ + topLeft: { x: 10, y: 10 }, + topRight: { x: 10.1, y: 10 }, + bottomRight: { x: 10.1, y: 10.1 }, + bottomLeft: { x: 10, y: 10.1 }, + })).toBe(false); + }); +}); From e26482e52c2dedb698858e90b8d8f906795d83d6 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:28:36 +0200 Subject: [PATCH 4/9] feat: render four-corner perspective in image mode --- src/lib/renderImageMockup.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib/renderImageMockup.ts b/src/lib/renderImageMockup.ts index e234032..4e45ee6 100644 --- a/src/lib/renderImageMockup.ts +++ b/src/lib/renderImageMockup.ts @@ -1,4 +1,5 @@ import { loadImageFromFile } from "./images"; +import { drawPerspectiveImage, isValidPerspectiveCorners } from "./perspective"; import { computePlacementMetrics, clamp, type PlacementMetrics } from "./renderPlacement"; import type { MockupSettings } from "../types"; @@ -55,6 +56,24 @@ export async function renderImageMockup(mockup: File, design: File, settings: Mo ctx.clearRect(0, 0, width, height); ctx.drawImage(mockupImage, 0, 0, width, height); + const perspective = settings.perspective; + if (perspective?.enabled) { + if (!isValidPerspectiveCorners(perspective.corners)) { + throw new Error("Perspective corners form an invalid or self-intersecting quadrilateral."); + } + + ctx.save(); + try { + ctx.globalAlpha = clamp(settings.opacity, 0, 100) / 100; + if (!drawPerspectiveImage(ctx, designImage, perspective.corners, width, height)) { + throw new Error("Perspective transform could not be rendered."); + } + } finally { + ctx.restore(); + } + return canvasToPngBlob(canvas); + } + const metrics = computePlacementMetrics(settings, { width: designImage.naturalWidth || designImage.width || 1, height: designImage.naturalHeight || designImage.height || 1, From ccef8378afd40759ed1491469163e53517a7871e Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:35:01 +0200 Subject: [PATCH 5/9] feat: add draggable perspective corner editor --- src/components/VisualPlacementEditor.tsx | 208 ++++++++++++++++------- 1 file changed, 149 insertions(+), 59 deletions(-) diff --git a/src/components/VisualPlacementEditor.tsx b/src/components/VisualPlacementEditor.tsx index a8eb9ce..08b28c7 100644 --- a/src/components/VisualPlacementEditor.tsx +++ b/src/components/VisualPlacementEditor.tsx @@ -1,6 +1,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { DocumentSize, LoadedAsset, MockupSettings } from "../types"; +import type { DocumentSize, LoadedAsset, MockupSettings, PerspectiveCorners } from "../types"; import { angleFrom, clamp, computePlacementMetrics, normalizeAngle } from "../lib/renderPlacement"; +import { + clampPerspectivePoint, + drawPerspectiveImage, + isValidPerspectiveCorners, + type PerspectiveCornerName, +} from "../lib/perspective"; interface VisualPlacementEditorProps { documentSize: DocumentSize; @@ -12,7 +18,7 @@ interface VisualPlacementEditorProps { mode?: "psd" | "image"; } -type DragMode = "design-move" | "design-scale" | "design-rotate" | null; +type DragMode = "design-move" | "design-scale" | "design-rotate" | "perspective" | null; interface DragState { mode: DragMode; @@ -27,10 +33,19 @@ interface DragState { startScaleHeight?: number; displayWidth?: number; displayHeight?: number; + perspectiveCorner?: PerspectiveCornerName; } +const perspectiveLabels: Record = { + topLeft: "Top left", + topRight: "Top right", + bottomRight: "Bottom right", + bottomLeft: "Bottom left", +}; + export function VisualPlacementEditor({ documentSize, design, mockup, settings, onChange, disabled, mode = "psd" }: VisualPlacementEditorProps) { const ref = useRef(null); + const perspectiveCanvasRef = useRef(null); const [drag, setDrag] = useState(null); const [showGuides, setShowGuides] = useState(true); const [designSize, setDesignSize] = useState<{ width: number; height: number } | null>(null); @@ -47,12 +62,36 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings, }, [design]); const aspect = documentSize.width > 0 && documentSize.height > 0 ? documentSize.width / documentSize.height : 1.5; - const metrics = useMemo(() => computePlacementMetrics(settings, designSize), [designSize, settings]); - const usesCoverClip = mode === "image" && settings.fitMode === "cover" && Boolean(design); + const usesPerspective = mode === "image" && Boolean(settings.perspective?.enabled && design); + const usesCoverClip = !usesPerspective && mode === "image" && settings.fitMode === "cover" && Boolean(design); const targetWidth = Math.max(0.001, metrics.targetWidth); const targetHeight = Math.max(0.001, metrics.targetHeight); + useEffect(() => { + const canvas = perspectiveCanvasRef.current; + const corners = settings.perspective?.corners; + if (!canvas || !usesPerspective || !design || !corners) return; + + const width = Math.max(1, Math.round(documentSize.width || 1200)); + const height = Math.max(1, Math.round(documentSize.height || 800)); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, width, height); + + const image = new Image(); + image.onload = () => { + ctx.clearRect(0, 0, width, height); + ctx.save(); + ctx.globalAlpha = clamp(settings.opacity, 0, 100) / 100; + drawPerspectiveImage(ctx, image, corners, width, height); + ctx.restore(); + }; + image.src = design.url; + }, [design, documentSize.height, documentSize.width, settings.opacity, settings.perspective, usesPerspective]); + function pointerPercent(event: React.PointerEvent): { x: number; y: number } | null { const element = ref.current; if (!element) return null; @@ -63,7 +102,7 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings, }; } - function beginDrag(event: React.PointerEvent, dragMode: DragMode): void { + function beginDrag(event: React.PointerEvent, dragMode: DragMode, perspectiveCorner?: PerspectiveCornerName): void { if (disabled || !dragMode) return; event.preventDefault(); event.stopPropagation(); @@ -76,6 +115,7 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings, startX: point.x, startY: point.y, initial: { ...settings }, + perspectiveCorner, }; if (dragMode === "design-rotate") { @@ -97,11 +137,27 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings, setDrag(next); } + function updatePerspectiveCorner(point: { x: number; y: number }, corner: PerspectiveCornerName): void { + const perspective = settings.perspective; + if (!perspective?.enabled) return; + const nextCorners: PerspectiveCorners = { + ...perspective.corners, + [corner]: clampPerspectivePoint(point), + }; + if (!isValidPerspectiveCorners(nextCorners)) return; + onChange({ ...settings, perspective: { enabled: true, corners: nextCorners } }); + } + function updateDrag(event: React.PointerEvent): void { if (!drag || disabled) return; const point = pointerPercent(event); if (!point) return; + if (drag.mode === "perspective" && drag.perspectiveCorner) { + updatePerspectiveCorner(point, drag.perspectiveCorner); + return; + } + const dx = point.x - drag.startX; const dy = point.y - drag.startY; const base = drag.initial; @@ -137,10 +193,13 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings, } const slotLabel = mode === "image" ? "Placement Area" : "SmartObject Slot"; - const panelTitle = mode === "image" ? "Image Mockup Editor" : "SmartObject Editor"; - const panelDescription = mode === "image" - ? "The mockup image stays fixed. Drag, scale and rotate only the uploaded design." - : "The SmartObject slot is fixed. Drag, scale and rotate only the uploaded design."; + const panelTitle = usesPerspective ? "Perspective Editor" : mode === "image" ? "Image Mockup Editor" : "SmartObject Editor"; + const panelDescription = usesPerspective + ? "Drag the four corner handles to match the surface perspective. The batch export uses the same transform." + : mode === "image" + ? "The mockup image stays fixed. Drag, scale and rotate only the uploaded design." + : "The SmartObject slot is fixed. Drag, scale and rotate only the uploaded design."; + const perspectiveCorners = settings.perspective?.corners; return (
@@ -168,17 +227,44 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings, {documentSize.width}×{documentSize.height} -
- {slotLabel} -
+ {!usesPerspective ? ( +
+ {slotLabel} +
+ ) : null} + + {usesPerspective && perspectiveCorners ? ( + <> +
From cbf9d6c20b3428e0c961e836753882194d2e7cff Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:35:13 +0200 Subject: [PATCH 6/9] style: add perspective editor overlay --- src/perspective.css | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/perspective.css diff --git a/src/perspective.css b/src/perspective.css new file mode 100644 index 0000000..fe4059b --- /dev/null +++ b/src/perspective.css @@ -0,0 +1,43 @@ +.live-editor__perspective-canvas, +.live-editor__perspective-outline { + inset: 0; + height: 100%; + pointer-events: none; + position: absolute; + width: 100%; + z-index: 3; +} + +.live-editor__perspective-canvas { + object-fit: fill; +} + +.live-editor__perspective-outline polygon { + fill: rgba(20, 184, 166, 0.08); + stroke: #14b8a6; + stroke-dasharray: 2 1.2; + stroke-width: 0.7; + vector-effect: non-scaling-stroke; +} + +.live-editor__perspective-handle { + background: #14b8a6; + border: 2px solid #ffffff; + border-radius: 999px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.35); + cursor: move; + height: 22px; + min-height: 0; + min-width: 0; + padding: 0; + position: absolute; + transform: translate(-50%, -50%); + width: 22px; + z-index: 6; +} + +.live-editor__perspective-handle:hover, +.live-editor__perspective-handle:focus-visible { + outline: 3px solid rgba(20, 184, 166, 0.28); + outline-offset: 2px; +} From ea9af0e303fad95543bf13c1a36aa37bc27e41c3 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:35:30 +0200 Subject: [PATCH 7/9] style: load perspective editor styles --- src/main.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.tsx b/src/main.tsx index 9202ab3..6d8d2ca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import App from "./App"; import { DemoBanner } from "./components/DemoBanner"; import "./styles.css"; import "./coverClip.css"; +import "./perspective.css"; const isStaticDemo = import.meta.env.MODE === "demo"; From 575ed5295e08b2eb4ccaaef34bedf5a484f107af Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:35:56 +0200 Subject: [PATCH 8/9] feat: add perspective mode controls --- src/components/PerspectiveControls.tsx | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/components/PerspectiveControls.tsx diff --git a/src/components/PerspectiveControls.tsx b/src/components/PerspectiveControls.tsx new file mode 100644 index 0000000..b51f9ec --- /dev/null +++ b/src/components/PerspectiveControls.tsx @@ -0,0 +1,69 @@ +import type { MockupSettings, PerspectiveCorners } from "../types"; +import { isValidPerspectiveCorners } from "../lib/perspective"; + +interface PerspectiveControlsProps { + settings: MockupSettings; + onChange: (settings: MockupSettings) => void; + mode: "psd" | "image"; +} + +function cornersFromPlacement(settings: MockupSettings): PerspectiveCorners { + const left = Math.max(0, Math.min(100, settings.areaLeftPercent)); + const top = Math.max(0, Math.min(100, settings.areaTopPercent)); + const right = Math.max(left + 1, Math.min(100, settings.areaLeftPercent + settings.areaWidthPercent)); + const bottom = Math.max(top + 1, Math.min(100, settings.areaTopPercent + settings.areaHeightPercent)); + return { + topLeft: { x: left, y: top }, + topRight: { x: right, y: top }, + bottomRight: { x: right, y: bottom }, + bottomLeft: { x: left, y: bottom }, + }; +} + +export function PerspectiveControls({ settings, onChange, mode }: PerspectiveControlsProps) { + if (mode !== "image") return null; + + const enabled = Boolean(settings.perspective?.enabled); + + function enablePerspective(): void { + const existing = settings.perspective?.corners; + const corners = existing && isValidPerspectiveCorners(existing) + ? existing + : cornersFromPlacement(settings); + onChange({ ...settings, perspective: { enabled: true, corners } }); + } + + function disablePerspective(): void { + if (!settings.perspective) return; + onChange({ ...settings, perspective: { ...settings.perspective, enabled: false } }); + } + + function resetPerspective(): void { + onChange({ ...settings, perspective: { enabled: true, corners: cornersFromPlacement(settings) } }); + } + + return ( +
+
+

4-Corner Perspective

+ {enabled ? "Enabled" : "Optional"} +
+

+ Warp the design onto angled signs, frames, screens or packaging. When enabled, drag the four teal corners directly in the editor. +

+
+ + +
+ {enabled ? ( +

+ Perspective uses the four corners as the complete design surface. Move/scale/rotate controls are ignored until perspective is disabled. +

+ ) : null} +
+ ); +} From c6a908db30e6e9e63d51bb70a943f74580fec49f Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:36:41 +0200 Subject: [PATCH 9/9] feat: expose four-corner perspective controls --- src/components/SettingsPanel.tsx | 55 +++++++++++++++++++------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index eca6663..e946498 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,6 +1,7 @@ import type { DocumentSize, MockupSettings, SavedPreset } from "../types"; import type { SmartObjectBounds, SmartObjectCandidate } from "../lib/psdSmartObjectDetection"; import { shirtPlacementDefaults } from "../lib/config/defaults"; +import { PerspectiveControls } from "./PerspectiveControls"; interface SettingsPanelProps { settings: MockupSettings; @@ -149,35 +150,43 @@ export function SettingsPanel({ + +

Design Transform

- Editable + {settings.perspective?.enabled && isImageMode ? "Paused by perspective" : "Editable"}
-
- patch({ left })} /> - patch({ top })} /> - - patch({ rotation })} /> - patch({ opacity })} /> -
+ {settings.perspective?.enabled && isImageMode ? ( +

Disable 4-Corner Perspective to use move, scale, rotation and fit controls again.

+ ) : ( + <> +
+ patch({ left })} /> + patch({ top })} /> + + patch({ rotation })} /> + patch({ opacity })} /> +
-
- Fit Mode -
- {fitModes.map((mode) => ( - - ))} -
-
+
+ Fit Mode +
+ {fitModes.map((fitMode) => ( + + ))} +
+
+ + )}