refboard: complete image geometry rework — consumer adoption + tests

- Fix grouping.ts ungroup: transform display position (with flip
  compensation) through group transform, then back-calculate canonical
  data.x/y. Fixes visual jump for flipped images in rotated groups.
- Fix clipboard.ts + export.ts resolution estimation: account for
  scale when computing effective rendered width for texture ratio.
- Add imageTransforms.test.ts: 32 unit tests covering source rect,
  visible local rect, display transform, display geometry, editor
  geometry anchor preservation, crop rect round-trips, coordinate
  conversions, negative scale normalization, and world bounds invariants.
- Add vitest as dev dependency with "test" script.
- Include canonical geometry layer (imageTransforms.ts) and crop
  session rewrite (CropOverlay.ts) from prior work.
- Add image geometry rework plan document.
This commit is contained in:
Hiren Kangad
2026-03-13 13:37:42 +05:30
parent c874e34577
commit 9a4edd90c7
8 changed files with 867 additions and 79 deletions
+362 -1
View File
@@ -30,7 +30,8 @@
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.1.0"
}
},
"node_modules/@babel/code-frame": {
@@ -1437,6 +1438,13 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tanstack/react-store": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz",
@@ -1756,6 +1764,17 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -1765,6 +1784,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1903,6 +1929,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.0",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"@vitest/utils": "4.1.0",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
@@ -1924,6 +2063,16 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2042,6 +2191,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -2301,6 +2460,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -2402,6 +2568,26 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -3147,6 +3333,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
@@ -4075,6 +4271,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
@@ -4136,6 +4343,13 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4964,6 +5178,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
@@ -5012,6 +5233,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -5059,6 +5294,23 @@
"node": ">=12"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5076,6 +5328,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -5515,6 +5777,88 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.0",
"@vitest/mocker": "4.1.0",
"@vitest/pretty-format": "4.1.0",
"@vitest/runner": "4.1.0",
"@vitest/snapshot": "4.1.0",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-preview": "4.1.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/ui": "4.1.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -5531,6 +5875,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+4 -2
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@blocknote/core": "^0.47.1",
@@ -31,6 +32,7 @@
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.1.0"
}
}
+37 -45
View File
@@ -10,7 +10,15 @@ import { Container, Graphics, FederatedPointerEvent, Polygon, Rectangle } from '
import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager';
import type { ImageObject, CropRect } from './scene-format';
import { getImageViewRectWorldCorners, imageViewPointToWorld, worldToImageViewPoint } from './imageTransforms';
import {
applyImageDisplayTransform,
displayCropRectToSourceCrop,
getImageDisplayCropRect,
getImageEditorGeometry,
getImageViewRectWorldCorners,
imageViewPointToWorld,
worldToImageViewPoint,
} from './imageTransforms';
import { ImageSprite } from './sprites/ImageSprite';
const HANDLE_SIZE = 8;
@@ -37,6 +45,7 @@ export class CropOverlay extends Container {
private _handles = new Map<HandleId, Graphics>();
private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 };
private _originalCrop: CropRect | undefined;
private _editorData: ImageObject | null = null;
private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null;
private _onConfirm: ((item: SceneItem, crop: CropRect, anchorWorld: { x: number; y: number }) => void) | null = null;
private _onCancel: (() => void) | null = null;
@@ -94,9 +103,12 @@ export class CropOverlay extends Container {
this._item = item;
const imgData = item.data as ImageObject;
this._originalCrop = imgData.crop ? { ...imgData.crop } : undefined;
this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 };
const editor = getImageEditorGeometry(imgData);
this._editorData = editor.editorData;
this._crop = editor.sourceCrop;
if (item.displayObject instanceof ImageSprite) {
item.displayObject.applyCrop(undefined);
applyImageDisplayTransform(item.displayObject, this._editorData);
}
this._viewport.plugins.pause('drag');
this.visible = true;
@@ -120,8 +132,8 @@ export class CropOverlay extends Container {
if (!this._item) return;
const item = this._item;
const crop = { ...this._crop };
const data = this._getWorkingImageData();
const viewCrop = this._getViewCrop();
const data = this._getEditorData();
const viewCrop = getImageDisplayCropRect({ crop, flipX: data.flipX, flipY: data.flipY });
const anchorWorld = imageViewPointToWorld(data, viewCrop.x, viewCrop.y);
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
this._cleanup();
@@ -130,9 +142,6 @@ export class CropOverlay extends Container {
/** Cancel cropping — restore original state. */
cancel(): void {
if (this._item?.displayObject instanceof ImageSprite) {
this._item.displayObject.applyCrop(this._originalCrop);
}
this._cleanup();
this._onCancel?.();
}
@@ -143,8 +152,10 @@ export class CropOverlay extends Container {
private _cleanup(): void {
this._removeDomDragListeners();
this._viewport.plugins.resume('drag');
this._restoreDisplayState();
this._item = null;
this._originalCrop = undefined;
this._editorData = null;
this._drag = null;
this.visible = false;
this._onStateChange?.(false);
@@ -162,9 +173,9 @@ export class CropOverlay extends Container {
private _draw(): void {
if (!this._item) return;
const data = this._getWorkingImageData();
const data = this._getEditorData();
const zoom = this._viewport.scale.x;
const viewCrop = this._getViewCrop();
const viewCrop = getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY });
const fullCorners = getImageViewRectWorldCorners(data, { x: 0, y: 0, w: 1, h: 1 });
const cropCorners = getImageViewRectWorldCorners(data, viewCrop);
@@ -241,12 +252,12 @@ export class CropOverlay extends Container {
if ('preventDefault' in e.nativeEvent && typeof e.nativeEvent.preventDefault === 'function') {
e.nativeEvent.preventDefault();
}
const data = this._getWorkingImageData();
const data = this._getEditorData();
const world = this._viewport.toWorld(e.global.x, e.global.y);
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
this._drag = {
mode,
startCrop: this._getViewCrop(),
startCrop: getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY }),
startPoint: { x: clamp01(viewPoint.x), y: clamp01(viewPoint.y) },
};
this._removeDomDragListeners();
@@ -260,7 +271,7 @@ export class CropOverlay extends Container {
private _onMoveScreen(screenX: number, screenY: number): void {
if (!this._drag || !this._item) return;
const data = this._getWorkingImageData();
const data = this._getEditorData();
const world = this._viewport.toWorld(screenX, screenY);
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
const nx = clamp01(viewPoint.x);
@@ -272,7 +283,7 @@ export class CropOverlay extends Container {
if (mode === 'move') {
crop.x = Math.max(0, Math.min(1 - sc.w, sc.x + (nx - startPoint.x)));
crop.y = Math.max(0, Math.min(1 - sc.h, sc.y + (ny - startPoint.y)));
this._setViewCrop(crop);
this._crop = displayCropRectToSourceCrop(data, crop);
this._draw();
return;
}
@@ -314,7 +325,7 @@ export class CropOverlay extends Container {
break;
}
this._setViewCrop(crop);
this._crop = displayCropRectToSourceCrop(data, crop);
this._draw();
}
@@ -329,31 +340,6 @@ export class CropOverlay extends Container {
this._removeDomDragListeners();
}
private _getViewCrop(): CropRect {
if (!this._item) return { ...this._crop };
const data = this._item.data as ImageObject;
return {
x: data.flipX ? 1 - (this._crop.x + this._crop.w) : this._crop.x,
y: data.flipY ? 1 - (this._crop.y + this._crop.h) : this._crop.y,
w: this._crop.w,
h: this._crop.h,
};
}
private _setViewCrop(viewCrop: CropRect): void {
if (!this._item) {
this._crop = { ...viewCrop };
return;
}
const data = this._item.data as ImageObject;
this._crop = {
x: data.flipX ? 1 - (viewCrop.x + viewCrop.w) : viewCrop.x,
y: data.flipY ? 1 - (viewCrop.y + viewCrop.h) : viewCrop.y,
w: viewCrop.w,
h: viewCrop.h,
};
}
private _drawPolygon(graphics: Graphics, points: Array<{ x: number; y: number }>): void {
if (points.length === 0) return;
graphics.moveTo(points[0].x, points[0].y);
@@ -390,11 +376,17 @@ export class CropOverlay extends Container {
};
}
private _getWorkingImageData(): ImageObject {
const data = this._item!.data as ImageObject;
return {
...data,
crop: undefined,
};
private _restoreDisplayState(): void {
if (!this._item || !(this._item.displayObject instanceof ImageSprite)) return;
const data = this._item.data as ImageObject;
this._item.displayObject.applyCrop(this._originalCrop);
applyImageDisplayTransform(this._item.displayObject, data);
}
private _getEditorData(): ImageObject {
if (!this._editorData) {
throw new Error('Crop editor data not initialized');
}
return this._editorData;
}
}
+2 -2
View File
@@ -91,8 +91,8 @@ export async function writeCanvasToClipboard(
for (const item of items) {
const tex = (item.displayObject as any)?.texture;
const displayW = item.type === 'image'
? getImageVisibleLocalRect(item.data as ImageObject).w
: item.data.w;
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
: item.data.w * Math.abs(item.data.sx);
if (tex && tex.width > 1 && displayW > 1) {
maxRatio = Math.max(maxRatio, tex.width / displayW);
}
+4 -4
View File
@@ -71,8 +71,8 @@ function renderToCanvas(
for (const item of items) {
const tex = (item.displayObject as any)?.texture;
const displayW = item.type === 'image'
? getImageVisibleLocalRect(item.data as ImageObject).w
: item.data.w;
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
: item.data.w * Math.abs(item.data.sx);
if (tex && tex.width > 1 && displayW > 1) {
maxRatio = Math.max(maxRatio, tex.width / displayW);
}
@@ -182,8 +182,8 @@ export function getExportDimensions(
for (const item of items) {
const tex = (item.displayObject as any)?.texture;
const displayW = item.type === 'image'
? getImageVisibleLocalRect(item.data as ImageObject).w
: item.data.w;
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
: item.data.w * Math.abs(item.data.sx);
if (tex && tex.width > 1 && displayW > 1) {
maxRatio = Math.max(maxRatio, tex.width / displayW);
}
+48 -25
View File
@@ -14,7 +14,7 @@ import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import type { GroupObject, ImageObject } from './scene-format';
import { randomFrameColor } from './sprites/FrameSprite';
import { applyImageDisplayTransform, transformPoint } from './imageTransforms';
import { applyImageDisplayTransform, getImageDisplayTransform, getImageVisibleLocalRect, transformPoint } from './imageTransforms';
/**
* Group selected items into a single group container.
@@ -132,32 +132,55 @@ export function ungroupItems(
const obj = childItem.displayObject;
// Convert local position to world space, accounting for group scale
const worldOrigin = transformPoint(
{ x: childItem.data.x, y: childItem.data.y },
{ x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle },
);
const worldX = worldOrigin.x;
const worldY = worldOrigin.y;
// Propagate group scale to child
childItem.data.sx *= groupSx;
childItem.data.sy *= groupSy;
// Propagate group angle to child
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
// Update DATA to store world coords
childItem.data.x = worldX;
childItem.data.y = worldY;
// Reparent display object
obj.parent?.removeChild(obj);
viewport.addChild(obj);
if (childItem.type === 'image') {
applyImageDisplayTransform(obj, childItem.data as ImageObject);
const imgData = childItem.data as ImageObject;
// For images, transform the display position (with flip compensation) to world,
// then back-calculate canonical data.x/y from the new scale/angle.
// This is necessary because data.x/y is the canonical anchor, not the visual
// top-left — the flip offset must be rotated through the group transform.
const localDisplay = getImageDisplayTransform(imgData);
const worldDisplay = transformPoint(
{ x: localDisplay.x, y: localDisplay.y },
{ x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle },
);
// Propagate group scale and angle
imgData.sx *= groupSx;
imgData.sy *= groupSy;
imgData.angle = (imgData.angle || 0) + groupAngle;
// Back-calculate canonical x/y: display.x = data.x + flipOffset
const newSx = Math.abs(imgData.sx);
const newSy = Math.abs(imgData.sy);
const visibleRect = getImageVisibleLocalRect(imgData);
imgData.x = worldDisplay.x - (imgData.flipX ? visibleRect.w * newSx : 0);
imgData.y = worldDisplay.y - (imgData.flipY ? visibleRect.h * newSy : 0);
// Reparent display object
obj.parent?.removeChild(obj);
viewport.addChild(obj);
applyImageDisplayTransform(obj, imgData);
} else {
obj.position.set(worldX, worldY);
// Convert local position to world space, accounting for group scale
const worldOrigin = transformPoint(
{ x: childItem.data.x, y: childItem.data.y },
{ x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle },
);
// Propagate group scale and angle
childItem.data.sx *= groupSx;
childItem.data.sy *= groupSy;
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
// Update DATA to store world coords
childItem.data.x = worldOrigin.x;
childItem.data.y = worldOrigin.y;
// Reparent display object
obj.parent?.removeChild(obj);
viewport.addChild(obj);
obj.position.set(worldOrigin.x, worldOrigin.y);
obj.scale.set(childItem.data.sx, childItem.data.sy);
obj.angle = childItem.data.angle;
}
+341
View File
@@ -0,0 +1,341 @@
import { describe, it, expect } from 'vitest';
import type { ImageObject } from './scene-format';
import {
getImageSourceRect,
getImageVisibleLocalRect,
getImageDisplayTransform,
getImageDisplayGeometry,
getImageEditorGeometry,
getImageDisplayCropRect,
displayCropRectToSourceCrop,
imageViewPointToWorld,
worldToImageViewPoint,
normalizeImageTransformData,
getImageTransformedCorners,
getImageWorldBounds,
} from './imageTransforms';
/** Helper to build a minimal ImageObject for testing. */
function makeImage(overrides: Partial<ImageObject> = {}): ImageObject {
return {
id: 'test',
type: 'image',
x: 0,
y: 0,
w: 200,
h: 100,
sx: 1,
sy: 1,
angle: 0,
z: 1,
opacity: 1,
locked: false,
name: '',
visible: true,
asset: 'test.png',
filters: [],
flipX: false,
flipY: false,
...overrides,
};
}
function expectClose(actual: number, expected: number, tolerance = 0.5) {
expect(Math.abs(actual - expected)).toBeLessThan(tolerance);
}
// ─── Source Rect ──────────────────────────────────────────────
describe('getImageSourceRect', () => {
it('returns full image when no crop', () => {
const r = getImageSourceRect(makeImage());
expect(r).toEqual({ x: 0, y: 0, w: 200, h: 100 });
});
it('returns cropped region in pixel coords', () => {
const r = getImageSourceRect(makeImage({ crop: { x: 0.25, y: 0.1, w: 0.5, h: 0.8 } }));
expect(r).toEqual({ x: 50, y: 10, w: 100, h: 80 });
});
});
// ─── Visible Local Rect ──────────────────────────────────────
describe('getImageVisibleLocalRect', () => {
it('full image → same as w/h', () => {
const r = getImageVisibleLocalRect(makeImage());
expect(r).toEqual({ x: 0, y: 0, w: 200, h: 100 });
});
it('cropped → visible dimensions match crop region', () => {
const r = getImageVisibleLocalRect(makeImage({ crop: { x: 0.25, y: 0, w: 0.5, h: 1 } }));
expect(r).toEqual({ x: 0, y: 0, w: 100, h: 100 });
});
});
// ─── Display Transform ───────────────────────────────────────
describe('getImageDisplayTransform', () => {
it('no flip → position equals data.x/y', () => {
const t = getImageDisplayTransform(makeImage({ x: 50, y: 30 }));
expect(t.x).toBe(50);
expect(t.y).toBe(30);
expect(t.scaleX).toBe(1);
expect(t.scaleY).toBe(1);
});
it('flipX → position shifts right by visible width', () => {
const t = getImageDisplayTransform(makeImage({ x: 50, y: 30, flipX: true }));
expect(t.x).toBe(250); // 50 + 200*1
expect(t.y).toBe(30);
expect(t.scaleX).toBe(-1);
});
it('flipY → position shifts down by visible height', () => {
const t = getImageDisplayTransform(makeImage({ x: 50, y: 30, flipY: true }));
expect(t.x).toBe(50);
expect(t.y).toBe(130); // 30 + 100*1
expect(t.scaleY).toBe(-1);
});
it('flipX + crop → shift uses cropped visible width', () => {
const data = makeImage({ x: 0, y: 0, flipX: true, crop: { x: 0, y: 0, w: 0.5, h: 1 } });
const t = getImageDisplayTransform(data);
expect(t.x).toBe(100); // 0 + 100*1 (half width cropped)
});
it('sx=2 + flipX → shift uses scaled visible width', () => {
const t = getImageDisplayTransform(makeImage({ x: 0, y: 0, sx: 2, flipX: true }));
expect(t.x).toBe(400); // 200 * 2
expect(t.scaleX).toBe(-2);
});
});
// ─── Display Geometry ────────────────────────────────────────
describe('getImageDisplayGeometry', () => {
it('world bounds match visible frame for unflipped image', () => {
const g = getImageDisplayGeometry(makeImage({ x: 100, y: 50 }));
expect(g.worldBounds.x).toBe(100);
expect(g.worldBounds.y).toBe(50);
expect(g.worldBounds.w).toBe(200);
expect(g.worldBounds.h).toBe(100);
});
it('flipped image has same world bounds as unflipped', () => {
const normal = getImageDisplayGeometry(makeImage({ x: 100, y: 50 }));
const flipped = getImageDisplayGeometry(makeImage({ x: 100, y: 50, flipX: true, flipY: true }));
expectClose(flipped.worldBounds.x, normal.worldBounds.x);
expectClose(flipped.worldBounds.y, normal.worldBounds.y);
expectClose(flipped.worldBounds.w, normal.worldBounds.w);
expectClose(flipped.worldBounds.h, normal.worldBounds.h);
});
it('cropped image has smaller world bounds', () => {
const g = getImageDisplayGeometry(makeImage({ x: 0, y: 0, crop: { x: 0, y: 0, w: 0.5, h: 0.5 } }));
expect(g.worldBounds.w).toBe(100); // half
expect(g.worldBounds.h).toBe(50); // half
});
});
// ─── Editor Geometry (crop session) ──────────────────────────
describe('getImageEditorGeometry', () => {
it('uncropped image → editor shows full image at same position', () => {
const data = makeImage({ x: 100, y: 50 });
const eg = getImageEditorGeometry(data);
expect(eg.sourceCrop).toEqual({ x: 0, y: 0, w: 1, h: 1 });
expectClose(eg.editorData.x, 100);
expectClose(eg.editorData.y, 50);
});
it('crop entry preserves visible region anchor', () => {
const data = makeImage({ x: 100, y: 50, crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 } });
const displayBefore = getImageDisplayGeometry(data);
const eg = getImageEditorGeometry(data);
// The crop anchor world point should match the display geometry's top-left
expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1);
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
});
it('crop entry after flipX preserves anchor', () => {
const data = makeImage({ x: 100, y: 50, flipX: true, crop: { x: 0.25, y: 0, w: 0.5, h: 1 } });
const displayBefore = getImageDisplayGeometry(data);
const eg = getImageEditorGeometry(data);
expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1);
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
});
it('crop entry after flipY preserves anchor', () => {
const data = makeImage({ x: 100, y: 50, flipY: true, crop: { x: 0, y: 0.25, w: 1, h: 0.5 } });
const displayBefore = getImageDisplayGeometry(data);
const eg = getImageEditorGeometry(data);
expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1);
expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1);
});
it('crop entry after flipX+flipY preserves anchor', () => {
const data = makeImage({ x: 100, y: 50, flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } });
// Anchor is the display crop's top-left in world space (not AABB min)
const displayCrop = getImageDisplayCropRect(data);
const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
const eg = getImageEditorGeometry(data);
expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01);
expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01);
// The editor's crop region should match the original crop region in world space
const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y);
expectClose(editorCropCorner.x, expectedAnchor.x, 1);
expectClose(editorCropCorner.y, expectedAnchor.y, 1);
});
it('crop entry after rotate preserves anchor', () => {
const data = makeImage({ x: 100, y: 50, angle: 45, crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 } });
const displayCrop = getImageDisplayCropRect(data);
const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
const eg = getImageEditorGeometry(data);
expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01);
expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01);
// The editor's crop region should match the original crop region in world space
const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y);
expectClose(editorCropCorner.x, expectedAnchor.x, 1);
expectClose(editorCropCorner.y, expectedAnchor.y, 1);
});
});
// ─── Crop Rect Conversions ───────────────────────────────────
describe('crop rect conversions', () => {
it('round-trip: display → source → display', () => {
const data = makeImage({ flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 } });
const display = getImageDisplayCropRect(data);
const source = displayCropRectToSourceCrop(data, display);
expectClose(source.x, data.crop!.x);
expectClose(source.y, data.crop!.y);
expectClose(source.w, data.crop!.w);
expectClose(source.h, data.crop!.h);
});
it('no flip → display crop equals source crop', () => {
const data = makeImage({ crop: { x: 0.2, y: 0.3, w: 0.4, h: 0.5 } });
const display = getImageDisplayCropRect(data);
expect(display.x).toBe(0.2);
expect(display.y).toBe(0.3);
});
it('flipX mirrors crop horizontally', () => {
const data = makeImage({ flipX: true, crop: { x: 0.1, y: 0, w: 0.3, h: 1 } });
const display = getImageDisplayCropRect(data);
expectClose(display.x, 0.6); // 1 - (0.1 + 0.3)
expect(display.w).toBe(0.3);
});
});
// ─── Coordinate Round-Trips ──────────────────────────────────
describe('coordinate conversions', () => {
it('source → world → source round-trip (no flip)', () => {
const data = makeImage({ x: 100, y: 50 });
const world = imageViewPointToWorld(data, 0.5, 0.5);
const back = worldToImageViewPoint(data, world.x, world.y);
expectClose(back.x, 0.5);
expectClose(back.y, 0.5);
});
it('source → world → source round-trip (flipX)', () => {
const data = makeImage({ x: 100, y: 50, flipX: true });
const world = imageViewPointToWorld(data, 0.3, 0.7);
const back = worldToImageViewPoint(data, world.x, world.y);
expectClose(back.x, 0.3);
expectClose(back.y, 0.7);
});
it('source → world → source round-trip (flipX + flipY + crop)', () => {
const data = makeImage({ x: 100, y: 50, flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } });
const world = imageViewPointToWorld(data, 0.4, 0.5);
const back = worldToImageViewPoint(data, world.x, world.y);
expectClose(back.x, 0.4);
expectClose(back.y, 0.5);
});
it('source → world → source round-trip (rotated)', () => {
const data = makeImage({ x: 100, y: 50, angle: 90 });
const world = imageViewPointToWorld(data, 0.25, 0.75);
const back = worldToImageViewPoint(data, world.x, world.y);
expectClose(back.x, 0.25);
expectClose(back.y, 0.75);
});
it('source → world → source round-trip (rotated + flipped + cropped)', () => {
const data = makeImage({ x: 100, y: 50, angle: 45, flipX: true, crop: { x: 0.2, y: 0.1, w: 0.6, h: 0.8 } });
const world = imageViewPointToWorld(data, 0.5, 0.5);
const back = worldToImageViewPoint(data, world.x, world.y);
expectClose(back.x, 0.5, 0.01);
expectClose(back.y, 0.5, 0.01);
});
});
// ─── Negative Scale Normalization ────────────────────────────
describe('normalizeImageTransformData', () => {
it('normalizes negative sx to positive + flipX', () => {
const data = makeImage({ sx: -2, flipX: false });
normalizeImageTransformData(data);
expect(data.sx).toBe(2);
expect(data.flipX).toBe(true);
});
it('normalizes negative sy to positive + flipY', () => {
const data = makeImage({ sy: -1.5, flipY: false });
normalizeImageTransformData(data);
expect(data.sy).toBe(1.5);
expect(data.flipY).toBe(true);
});
it('double negative sx toggles existing flipX', () => {
const data = makeImage({ sx: -1, flipX: true });
normalizeImageTransformData(data);
expect(data.sx).toBe(1);
expect(data.flipX).toBe(false); // was true, negated
});
it('positive scale is unchanged', () => {
const data = makeImage({ sx: 2, sy: 3, flipX: true, flipY: false });
normalizeImageTransformData(data);
expect(data.sx).toBe(2);
expect(data.sy).toBe(3);
expect(data.flipX).toBe(true);
expect(data.flipY).toBe(false);
});
});
// ─── World Bounds Invariants ─────────────────────────────────
describe('world bounds invariants', () => {
it('flip does not change world bounds', () => {
const base = makeImage({ x: 50, y: 50, sx: 1.5, sy: 1.5 });
const bounds0 = getImageWorldBounds(base);
const fx = makeImage({ ...base, flipX: true });
const boundsX = getImageWorldBounds(fx);
expectClose(boundsX.x, bounds0.x);
expectClose(boundsX.w, bounds0.w);
const fy = makeImage({ ...base, flipY: true });
const boundsY = getImageWorldBounds(fy);
expectClose(boundsY.y, bounds0.y);
expectClose(boundsY.h, bounds0.h);
});
it('crop reduces world bounds proportionally', () => {
const full = getImageWorldBounds(makeImage({ x: 0, y: 0 }));
const half = getImageWorldBounds(makeImage({ x: 0, y: 0, crop: { x: 0, y: 0, w: 0.5, h: 0.5 } }));
expectClose(half.w, full.w / 2);
expectClose(half.h, full.h / 2);
});
});
+69
View File
@@ -37,6 +37,11 @@ export interface ImageVisibleFrame {
display: ImageDisplayTransform;
}
export interface ImageDisplayGeometry extends ImageVisibleFrame {
worldCorners: Point2D[];
worldBounds: { x: number; y: number; w: number; h: number };
}
export interface ImageLocalRect {
x: number;
y: number;
@@ -44,6 +49,15 @@ export interface ImageLocalRect {
h: number;
}
export interface ImageEditorGeometry {
editorData: ImageObject;
sourceCrop: CropRect;
displayCrop: CropRect;
fullWorldCorners: Point2D[];
cropWorldCorners: Point2D[];
cropAnchorWorld: Point2D;
}
/**
* The canonical runtime image model:
* - `sourceRect` is the sampled rectangle inside the original asset
@@ -120,6 +134,61 @@ export function getImageVisibleFrame(data: Pick<ImageObject, 'x' | 'y' | 'w' | '
};
}
export function getImageDisplayCropRect(data: Pick<ImageObject, 'crop' | 'flipX' | 'flipY'>): CropRect {
const crop = data.crop ?? { x: 0, y: 0, w: 1, h: 1 };
return {
x: data.flipX ? 1 - (crop.x + crop.w) : crop.x,
y: data.flipY ? 1 - (crop.y + crop.h) : crop.y,
w: crop.w,
h: crop.h,
};
}
export function displayCropRectToSourceCrop(
data: Pick<ImageObject, 'flipX' | 'flipY'>,
displayCrop: CropRect,
): CropRect {
return {
x: data.flipX ? 1 - (displayCrop.x + displayCrop.w) : displayCrop.x,
y: data.flipY ? 1 - (displayCrop.y + displayCrop.h) : displayCrop.y,
w: displayCrop.w,
h: displayCrop.h,
};
}
export function getImageDisplayGeometry(
data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>,
): ImageDisplayGeometry {
const frame = getImageVisibleFrame(data);
const worldCorners = getImageTransformedCorners(data);
return {
...frame,
worldCorners,
worldBounds: getBoundsFromPoints(worldCorners),
};
}
export function getImageEditorGeometry(data: ImageObject): ImageEditorGeometry {
const sourceCrop = data.crop ? { ...data.crop } : { x: 0, y: 0, w: 1, h: 1 };
const displayCrop = getImageDisplayCropRect(data);
const cropAnchorWorld = imageViewPointToWorld(data, displayCrop.x, displayCrop.y);
const editorData: ImageObject = {
...data,
crop: undefined,
};
const editorAnchorWorld = imageViewPointToWorld(editorData, displayCrop.x, displayCrop.y);
editorData.x += cropAnchorWorld.x - editorAnchorWorld.x;
editorData.y += cropAnchorWorld.y - editorAnchorWorld.y;
return {
editorData,
sourceCrop,
displayCrop,
fullWorldCorners: getImageViewRectWorldCorners(editorData, { x: 0, y: 0, w: 1, h: 1 }),
cropWorldCorners: getImageViewRectWorldCorners(editorData, displayCrop),
cropAnchorWorld,
};
}
export function applyImageDisplayTransform(displayObject: Container, data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): void {
const t = getImageDisplayTransform(data);
displayObject.position.set(t.x, t.y);