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:
Generated
+362
-1
@@ -30,7 +30,8 @@
|
|||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1437,6 +1438,13 @@
|
|||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tanstack/react-store": {
|
||||||
"version": "0.7.7",
|
"version": "0.7.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz",
|
||||||
@@ -1756,6 +1764,17 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -1765,6 +1784,13 @@
|
|||||||
"@types/ms": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"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"
|
"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": {
|
"node_modules/@webgpu/types": {
|
||||||
"version": "0.1.69",
|
"version": "0.1.69",
|
||||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
|
||||||
@@ -1924,6 +2063,16 @@
|
|||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -2042,6 +2191,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/character-entities": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||||
@@ -2301,6 +2460,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
@@ -2402,6 +2568,26 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -3147,6 +3333,16 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.1",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
@@ -4075,6 +4271,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/orderedmap": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
@@ -4136,6 +4343,13 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -4964,6 +5178,13 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/socket.io-client": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
"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"
|
"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": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
@@ -5059,6 +5294,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -5076,6 +5328,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"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": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
@@ -5531,6 +5875,23 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/core": "^0.47.1",
|
"@blocknote/core": "^0.47.1",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import { Container, Graphics, FederatedPointerEvent, Polygon, Rectangle } from '
|
|||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { SceneItem } from './SceneManager';
|
import type { SceneItem } from './SceneManager';
|
||||||
import type { ImageObject, CropRect } from './scene-format';
|
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';
|
import { ImageSprite } from './sprites/ImageSprite';
|
||||||
|
|
||||||
const HANDLE_SIZE = 8;
|
const HANDLE_SIZE = 8;
|
||||||
@@ -37,6 +45,7 @@ export class CropOverlay extends Container {
|
|||||||
private _handles = new Map<HandleId, Graphics>();
|
private _handles = new Map<HandleId, Graphics>();
|
||||||
private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 };
|
private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 };
|
||||||
private _originalCrop: CropRect | undefined;
|
private _originalCrop: CropRect | undefined;
|
||||||
|
private _editorData: ImageObject | null = null;
|
||||||
private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | 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 _onConfirm: ((item: SceneItem, crop: CropRect, anchorWorld: { x: number; y: number }) => void) | null = null;
|
||||||
private _onCancel: (() => void) | null = null;
|
private _onCancel: (() => void) | null = null;
|
||||||
@@ -94,9 +103,12 @@ export class CropOverlay extends Container {
|
|||||||
this._item = item;
|
this._item = item;
|
||||||
const imgData = item.data as ImageObject;
|
const imgData = item.data as ImageObject;
|
||||||
this._originalCrop = imgData.crop ? { ...imgData.crop } : undefined;
|
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) {
|
if (item.displayObject instanceof ImageSprite) {
|
||||||
item.displayObject.applyCrop(undefined);
|
item.displayObject.applyCrop(undefined);
|
||||||
|
applyImageDisplayTransform(item.displayObject, this._editorData);
|
||||||
}
|
}
|
||||||
this._viewport.plugins.pause('drag');
|
this._viewport.plugins.pause('drag');
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
@@ -120,8 +132,8 @@ export class CropOverlay extends Container {
|
|||||||
if (!this._item) return;
|
if (!this._item) return;
|
||||||
const item = this._item;
|
const item = this._item;
|
||||||
const crop = { ...this._crop };
|
const crop = { ...this._crop };
|
||||||
const data = this._getWorkingImageData();
|
const data = this._getEditorData();
|
||||||
const viewCrop = this._getViewCrop();
|
const viewCrop = getImageDisplayCropRect({ crop, flipX: data.flipX, flipY: data.flipY });
|
||||||
const anchorWorld = imageViewPointToWorld(data, viewCrop.x, viewCrop.y);
|
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;
|
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
|
||||||
this._cleanup();
|
this._cleanup();
|
||||||
@@ -130,9 +142,6 @@ export class CropOverlay extends Container {
|
|||||||
|
|
||||||
/** Cancel cropping — restore original state. */
|
/** Cancel cropping — restore original state. */
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
if (this._item?.displayObject instanceof ImageSprite) {
|
|
||||||
this._item.displayObject.applyCrop(this._originalCrop);
|
|
||||||
}
|
|
||||||
this._cleanup();
|
this._cleanup();
|
||||||
this._onCancel?.();
|
this._onCancel?.();
|
||||||
}
|
}
|
||||||
@@ -143,8 +152,10 @@ export class CropOverlay extends Container {
|
|||||||
private _cleanup(): void {
|
private _cleanup(): void {
|
||||||
this._removeDomDragListeners();
|
this._removeDomDragListeners();
|
||||||
this._viewport.plugins.resume('drag');
|
this._viewport.plugins.resume('drag');
|
||||||
|
this._restoreDisplayState();
|
||||||
this._item = null;
|
this._item = null;
|
||||||
this._originalCrop = undefined;
|
this._originalCrop = undefined;
|
||||||
|
this._editorData = null;
|
||||||
this._drag = null;
|
this._drag = null;
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
this._onStateChange?.(false);
|
this._onStateChange?.(false);
|
||||||
@@ -162,9 +173,9 @@ export class CropOverlay extends Container {
|
|||||||
|
|
||||||
private _draw(): void {
|
private _draw(): void {
|
||||||
if (!this._item) return;
|
if (!this._item) return;
|
||||||
const data = this._getWorkingImageData();
|
const data = this._getEditorData();
|
||||||
const zoom = this._viewport.scale.x;
|
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 fullCorners = getImageViewRectWorldCorners(data, { x: 0, y: 0, w: 1, h: 1 });
|
||||||
const cropCorners = getImageViewRectWorldCorners(data, viewCrop);
|
const cropCorners = getImageViewRectWorldCorners(data, viewCrop);
|
||||||
|
|
||||||
@@ -241,12 +252,12 @@ export class CropOverlay extends Container {
|
|||||||
if ('preventDefault' in e.nativeEvent && typeof e.nativeEvent.preventDefault === 'function') {
|
if ('preventDefault' in e.nativeEvent && typeof e.nativeEvent.preventDefault === 'function') {
|
||||||
e.nativeEvent.preventDefault();
|
e.nativeEvent.preventDefault();
|
||||||
}
|
}
|
||||||
const data = this._getWorkingImageData();
|
const data = this._getEditorData();
|
||||||
const world = this._viewport.toWorld(e.global.x, e.global.y);
|
const world = this._viewport.toWorld(e.global.x, e.global.y);
|
||||||
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
|
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
|
||||||
this._drag = {
|
this._drag = {
|
||||||
mode,
|
mode,
|
||||||
startCrop: this._getViewCrop(),
|
startCrop: getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY }),
|
||||||
startPoint: { x: clamp01(viewPoint.x), y: clamp01(viewPoint.y) },
|
startPoint: { x: clamp01(viewPoint.x), y: clamp01(viewPoint.y) },
|
||||||
};
|
};
|
||||||
this._removeDomDragListeners();
|
this._removeDomDragListeners();
|
||||||
@@ -260,7 +271,7 @@ export class CropOverlay extends Container {
|
|||||||
private _onMoveScreen(screenX: number, screenY: number): void {
|
private _onMoveScreen(screenX: number, screenY: number): void {
|
||||||
if (!this._drag || !this._item) return;
|
if (!this._drag || !this._item) return;
|
||||||
|
|
||||||
const data = this._getWorkingImageData();
|
const data = this._getEditorData();
|
||||||
const world = this._viewport.toWorld(screenX, screenY);
|
const world = this._viewport.toWorld(screenX, screenY);
|
||||||
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
|
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
|
||||||
const nx = clamp01(viewPoint.x);
|
const nx = clamp01(viewPoint.x);
|
||||||
@@ -272,7 +283,7 @@ export class CropOverlay extends Container {
|
|||||||
if (mode === 'move') {
|
if (mode === 'move') {
|
||||||
crop.x = Math.max(0, Math.min(1 - sc.w, sc.x + (nx - startPoint.x)));
|
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)));
|
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();
|
this._draw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,7 +325,7 @@ export class CropOverlay extends Container {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._setViewCrop(crop);
|
this._crop = displayCropRectToSourceCrop(data, crop);
|
||||||
this._draw();
|
this._draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,31 +340,6 @@ export class CropOverlay extends Container {
|
|||||||
this._removeDomDragListeners();
|
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 {
|
private _drawPolygon(graphics: Graphics, points: Array<{ x: number; y: number }>): void {
|
||||||
if (points.length === 0) return;
|
if (points.length === 0) return;
|
||||||
graphics.moveTo(points[0].x, points[0].y);
|
graphics.moveTo(points[0].x, points[0].y);
|
||||||
@@ -390,11 +376,17 @@ export class CropOverlay extends Container {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getWorkingImageData(): ImageObject {
|
private _restoreDisplayState(): void {
|
||||||
const data = this._item!.data as ImageObject;
|
if (!this._item || !(this._item.displayObject instanceof ImageSprite)) return;
|
||||||
return {
|
const data = this._item.data as ImageObject;
|
||||||
...data,
|
this._item.displayObject.applyCrop(this._originalCrop);
|
||||||
crop: undefined,
|
applyImageDisplayTransform(this._item.displayObject, data);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
private _getEditorData(): ImageObject {
|
||||||
|
if (!this._editorData) {
|
||||||
|
throw new Error('Crop editor data not initialized');
|
||||||
|
}
|
||||||
|
return this._editorData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export async function writeCanvasToClipboard(
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const tex = (item.displayObject as any)?.texture;
|
const tex = (item.displayObject as any)?.texture;
|
||||||
const displayW = item.type === 'image'
|
const displayW = item.type === 'image'
|
||||||
? getImageVisibleLocalRect(item.data as ImageObject).w
|
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||||
: item.data.w;
|
: item.data.w * Math.abs(item.data.sx);
|
||||||
if (tex && tex.width > 1 && displayW > 1) {
|
if (tex && tex.width > 1 && displayW > 1) {
|
||||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ function renderToCanvas(
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const tex = (item.displayObject as any)?.texture;
|
const tex = (item.displayObject as any)?.texture;
|
||||||
const displayW = item.type === 'image'
|
const displayW = item.type === 'image'
|
||||||
? getImageVisibleLocalRect(item.data as ImageObject).w
|
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||||
: item.data.w;
|
: item.data.w * Math.abs(item.data.sx);
|
||||||
if (tex && tex.width > 1 && displayW > 1) {
|
if (tex && tex.width > 1 && displayW > 1) {
|
||||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||||
}
|
}
|
||||||
@@ -182,8 +182,8 @@ export function getExportDimensions(
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const tex = (item.displayObject as any)?.texture;
|
const tex = (item.displayObject as any)?.texture;
|
||||||
const displayW = item.type === 'image'
|
const displayW = item.type === 'image'
|
||||||
? getImageVisibleLocalRect(item.data as ImageObject).w
|
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||||
: item.data.w;
|
: item.data.w * Math.abs(item.data.sx);
|
||||||
if (tex && tex.width > 1 && displayW > 1) {
|
if (tex && tex.width > 1 && displayW > 1) {
|
||||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
|
|||||||
import type { SelectionManager } from './SelectionManager';
|
import type { SelectionManager } from './SelectionManager';
|
||||||
import type { GroupObject, ImageObject } from './scene-format';
|
import type { GroupObject, ImageObject } from './scene-format';
|
||||||
import { randomFrameColor } from './sprites/FrameSprite';
|
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.
|
* Group selected items into a single group container.
|
||||||
@@ -132,32 +132,55 @@ export function ungroupItems(
|
|||||||
|
|
||||||
const obj = childItem.displayObject;
|
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') {
|
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 {
|
} 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.scale.set(childItem.data.sx, childItem.data.sy);
|
||||||
obj.angle = childItem.data.angle;
|
obj.angle = childItem.data.angle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,11 @@ export interface ImageVisibleFrame {
|
|||||||
display: ImageDisplayTransform;
|
display: ImageDisplayTransform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageDisplayGeometry extends ImageVisibleFrame {
|
||||||
|
worldCorners: Point2D[];
|
||||||
|
worldBounds: { x: number; y: number; w: number; h: number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImageLocalRect {
|
export interface ImageLocalRect {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -44,6 +49,15 @@ export interface ImageLocalRect {
|
|||||||
h: number;
|
h: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageEditorGeometry {
|
||||||
|
editorData: ImageObject;
|
||||||
|
sourceCrop: CropRect;
|
||||||
|
displayCrop: CropRect;
|
||||||
|
fullWorldCorners: Point2D[];
|
||||||
|
cropWorldCorners: Point2D[];
|
||||||
|
cropAnchorWorld: Point2D;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The canonical runtime image model:
|
* The canonical runtime image model:
|
||||||
* - `sourceRect` is the sampled rectangle inside the original asset
|
* - `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 {
|
export function applyImageDisplayTransform(displayObject: Container, data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): void {
|
||||||
const t = getImageDisplayTransform(data);
|
const t = getImageDisplayTransform(data);
|
||||||
displayObject.position.set(t.x, t.y);
|
displayObject.position.set(t.x, t.y);
|
||||||
|
|||||||
Reference in New Issue
Block a user