From 09137bc2e097cd756538b8eb533aa60afc79b677 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 20:58:24 -0400 Subject: [PATCH 01/14] test: peer ranges accept the shipped host (core 1.x, three 0.186), failing first Co-Authored-By: Claude Opus 5.5 (1M context) --- src/package.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/package.test.ts diff --git a/src/package.test.ts b/src/package.test.ts new file mode 100644 index 0000000..62aec6c --- /dev/null +++ b/src/package.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + peerDependencies: Record +} + +// The host ships Pascal 1.x on three 0.186 (@pascal-app/core 1.0.3 pins `three ^0.186`); +// strict installs refuse the plugin when a peer range excludes them. +const shippedHost = { + '@pascal-app/core': ['1.0.0-beta.4', '1.0.3', '1.9.0'], + '@pascal-app/editor': ['1.0.0-beta.4', '1.0.3', '1.9.0'], + '@pascal-app/viewer': ['1.0.0-beta.4', '1.0.3', '1.9.0'], + three: ['0.185.0', '0.186.0', '0.186.1'], +} as const + +describe('peer ranges', () => { + for (const [name, versions] of Object.entries(shippedHost)) { + test(`${name} accepts ${versions.join(', ')}`, () => { + const range = manifest.peerDependencies[name] + expect(range).toBeDefined() + for (const version of versions) { + expect({ version, accepted: Bun.semver.satisfies(version, range ?? '') }).toEqual({ + version, + accepted: true, + }) + } + }) + } + + test('does not claim the next major', () => { + for (const name of ['@pascal-app/core', '@pascal-app/editor', '@pascal-app/viewer']) { + expect(Bun.semver.satisfies('2.0.0', manifest.peerDependencies[name] ?? '')).toBe(false) + } + expect(Bun.semver.satisfies('0.187.0', manifest.peerDependencies.three ?? '')).toBe(false) + }) +}) From b14253a508875a0a79f890c91d4995c935dca728 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 20:59:34 -0400 Subject: [PATCH 02/14] fix: accept Pascal 1.x and three 0.186 as peers The host ships @pascal-app/core 1.0.3 on three 0.186, which the <1.0.0 and ^0.185 ranges excluded, so strict installs refused the plugin. Dev dependencies follow the host (core/editor/viewer 1.0.3, three 0.186.0, @types/three 0.184.1 as in pascalorg/editor). Co-Authored-By: Claude Opus 5.5 (1M context) --- bun.lock | 94 ++++++++++++++++++++++++++++++++++++++++++++-------- package.json | 18 +++++----- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/bun.lock b/bun.lock index 013b55e..8e189be 100644 --- a/bun.lock +++ b/bun.lock @@ -8,9 +8,9 @@ "urdf-loader": "^0.13.1", }, "devDependencies": { - "@pascal-app/core": "1.0.0-beta.4", - "@pascal-app/editor": "1.0.0-beta.4", - "@pascal-app/viewer": "1.0.0-beta.4", + "@pascal-app/core": "1.0.3", + "@pascal-app/editor": "1.0.3", + "@pascal-app/viewer": "1.0.3", "@react-three/fiber": "^9.5.0", "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.5", @@ -19,21 +19,21 @@ "@types/pdfkit": "^0.17.3", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@types/three": "^0.185.0", + "@types/three": "0.184.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "three": "^0.185.0", + "three": "0.186.0", "typescript": "6.0.3", "zod": "^4.3.6", "zustand": "^5.0.11", }, "peerDependencies": { - "@pascal-app/core": ">=1.0.0-beta.4 <1.0.0", - "@pascal-app/editor": ">=1.0.0-beta.4 <1.0.0", - "@pascal-app/viewer": ">=1.0.0-beta.4 <1.0.0", + "@pascal-app/core": ">=1.0.0-beta.4 <2.0.0", + "@pascal-app/editor": ">=1.0.0-beta.4 <2.0.0", + "@pascal-app/viewer": ">=1.0.0-beta.4 <2.0.0", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.185 || ^0.186", "zod": "^4", "zustand": "^5", }, @@ -62,6 +62,12 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@gltf-transform/core": ["@gltf-transform/core@4.5.0", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-JlvShvnB4AtfGZqjnESS/wfPJbpVnyHhTwJBbWN4qXqkB3C5HIoxxpu/orwGBge6Pc0zObeWj70lVf+4gbuu0A=="], + + "@gltf-transform/extensions": ["@gltf-transform/extensions@4.5.0", "", { "dependencies": { "@gltf-transform/core": "^4.5.0", "ktx-parse": "^1.1.0" } }, "sha512-J4cKzrmEr75YkrtdI4cKosBg2vDC4rT56aq/QoijW7GT1Y9gSEMJbqyFO9xcFO3FNpZaZiDrHWUqE5Okrl5nOQ=="], + + "@gltf-transform/functions": ["@gltf-transform/functions@4.5.0", "", { "dependencies": { "@gltf-transform/core": "^4.5.0", "@gltf-transform/extensions": "^4.5.0", "ktx-parse": "^1.1.0", "ndarray": "^1.0.19", "ndarray-lanczos": "^0.3.0", "ndarray-pixels": "^5.2.0" } }, "sha512-7sMOuEfksMyF+/NTwUnEOgh7djohJ5XfeYg8rzXDG+9JIdbGVUpvJUY3XVBJCUJJ04dusDvcTHloOi1wfM8lnw=="], + "@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], @@ -120,6 +126,14 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jscadui/3mf-export": ["@jscadui/3mf-export@0.5.0", "", {}, "sha512-y5vZktqCjyi7wA38zqNlLIdZUIRZoOO9vCjLzwmL4bR0hk7B/Zm1IeffzJPFe1vFc0C1IEv3hm8caDv3doRk9g=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], "@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="], @@ -146,15 +160,17 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@number-flow/react": ["@number-flow/react@0.6.2", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.6.2" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA=="], - "@pascal-app/core": ["@pascal-app/core@1.0.0-beta.4", "", { "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", "zod": "^4.3.5", "zundo": "^2.3.0", "zustand": "^5" }, "peerDependencies": { "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185" } }, "sha512-g07X0t28crhbYrTQcrWImLnRuB/zDLLGAnAt16T8zyp5UBxMMmeh3kPqSqqZQxkDGx261PUG31Qf06TlKcniuw=="], + "@pascal-app/core": ["@pascal-app/core@1.0.3", "", { "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", "zod": ">=4.5.4 <4.6", "zundo": "^2.3.0", "zustand": "^5" }, "peerDependencies": { "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186" } }, "sha512-v95AfNJZDG/N7Biwt/ZQCtZuulRtqNuwAiT3cb1/F63hqZmqmoZnTLZGCu+w0Kxu57UFK084YrbtUP+cbGo1sQ=="], - "@pascal-app/editor": ["@pascal-app/editor@1.0.0-beta.4", "", { "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "howler": "^2.2.4", "lucide-react": "^1.7.0", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.4", "@pascal-app/viewer": "^1.0.0-beta.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185" } }, "sha512-2ED/NkN+IfimU7rA3ISv7EeVF/MjYOEaXtoIjSLSIrQyf61jWt7Mdt0pdpxR0dm9VGnRa03MqPCG0DJgw2mF6A=="], + "@pascal-app/editor": ["@pascal-app/editor@1.0.3", "", { "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", "manifold-3d": "3.5.1", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "~0.9.8", "zod": ">=4.5.4 <4.6", "zustand": "^5.0.11" }, "peerDependencies": { "@pascal-app/core": "^1.0.3", "@pascal-app/viewer": "^1.0.3", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186" } }, "sha512-+LSJ+mxj3JsTM18aay3qexn6OKGinN38ZBko3vNdf79sge/iFWPOXquniiMpkEVtMz4lRHlLM2+fc6WvXT5RHA=="], "@pascal-app/lingo": ["@pascal-app/lingo@0.2.1", "", { "peerDependencies": { "react": "^19" }, "optionalPeers": ["react"] }, "sha512-Y6Nmb4vLSMp/dIdKABc7GjdTdB6yvUyBg95ySlThMz6zKrJjpVG0utexY4cnmWVPkGNSKOFotiHw8UJiQMUVnw=="], - "@pascal-app/viewer": ["@pascal-app/viewer@1.0.0-beta.4", "", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185" } }, "sha512-R6TGwIiOhzXNUIWjnG/Qy9GxGS7XQvWOOxgWFekjl3cdejl02M4Z98v90gVw7IDQGmZn/g5CCVJhRaKKtSHBBQ=="], + "@pascal-app/viewer": ["@pascal-app/viewer@1.0.3", "", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5" }, "peerDependencies": { "@pascal-app/core": "^1.0.3", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186" } }, "sha512-HCoKEPVDhDJOl+ydivkwnwpE2If+EK6BgSwMsF/SZqZQJ9jkpt5ZTL1osVutr6XQP916Hf/pbHML8qhWqYjBkw=="], "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], @@ -250,6 +266,8 @@ "@types/howler": ["@types/howler@2.2.13", "", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="], + "@types/ndarray": ["@types/ndarray@1.1.0", "", {}, "sha512-go+C6vHBUuO9jH6Tj6qEZ63JEGoMSxFdmd+6QAtthGD6xumBfIz3RbNDDs2aoRVLf9naH8lk5jnWy2UHHKgYQQ=="], + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], @@ -264,7 +282,7 @@ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], - "@types/three": ["@types/three@0.185.4", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA=="], + "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -276,6 +294,8 @@ "@visual-json/react": ["@visual-json/react@0.4.0", "", { "dependencies": { "@visual-json/core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-qiDrXI7wu9VoTE0TaLoJp7kwRj8SQTpkS/wkts5sBPA8078zdrIKc+0u0Y8wAXGYd3udw9dNfD4kmpO48OEwXQ=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -310,12 +330,18 @@ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="], @@ -328,10 +354,16 @@ "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], + "esbuild-wasm": ["esbuild-wasm@0.27.7", "", { "bin": { "esbuild": "bin/esbuild" } }, "sha512-1k03e2/tGz+sLz3/xzoZmUsIqtaGIvJa8k4UqUeqCUry83nHmlxQYZUUES0WBFUYilSQUf7nDUGAciIIklljSg=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], @@ -352,14 +384,22 @@ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="], + + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], @@ -368,6 +408,10 @@ "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "manifold-3d": ["manifold-3d@3.5.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.0", "@gltf-transform/extensions": "^4.2.0", "@gltf-transform/functions": "^4.2.0", "@jridgewell/resolve-uri": "^3.1.2", "@jridgewell/trace-mapping": "^0.3.31", "@jscadui/3mf-export": "^0.5.0", "commander": "^13.1.0", "convert-source-map": "^2.0.0", "fast-xml-parser": "^5.4.2", "fflate": "^0.8.0", "magic-string": "^0.30.21" }, "peerDependencies": { "esbuild-wasm": "^0.27.3" }, "bin": { "manifold-cad": "bin/manifold-cad" } }, "sha512-/+m6kxYMMhnPutcQ5oSmFJiJ+gyP/0fmuUCb9Qeaunvecm/bfqogKYDDJarsnWiFioSMtKheF+lGmSlnYCik9g=="], + "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="], "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], @@ -382,12 +426,22 @@ "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "ndarray": ["ndarray@1.1.1", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-AsE8w/M2MS+0pD3nP5qbRRtJFeJJJaJ+qXZaZr0XjVGXhy6LIj2TERTlkpdK2AI3WufoOP+pwPk3q2ilAJcZ9g=="], + + "ndarray-lanczos": ["ndarray-lanczos@0.3.0", "", { "dependencies": { "@types/ndarray": "^1.0.11", "ndarray": "^1.0.19" } }, "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg=="], + + "ndarray-ops": ["ndarray-ops@1.2.2", "", { "dependencies": { "cwise-compiler": "^1.0.0" } }, "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw=="], + + "ndarray-pixels": ["ndarray-pixels@5.2.0", "", { "dependencies": { "@types/ndarray": "^1.0.14", "ndarray": "^1.0.19", "ndarray-ops": "^1.2.2", "sharp": "^0.35.0" } }, "sha512-lTh4tFKziAatVTa9crIsidUyn+lqujVOQpzfdBWvdFu2wo9Uo6z261lVX7SgMyP89xGmj3TMTPbbxl9YDnV4SA=="], + "next": ["next@16.3.0", "", { "dependencies": { "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-x64": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-musl": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.0", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A=="], "number-flow": ["number-flow@0.6.2", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw=="], "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="], @@ -402,6 +456,8 @@ "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], + "property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], @@ -434,13 +490,15 @@ "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], + "three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], @@ -468,6 +526,8 @@ "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "uniq": ["uniq@1.0.1", "", {}, "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="], + "urdf-loader": ["urdf-loader@0.13.1", "", { "peerDependencies": { "three": ">=0.152.0" } }, "sha512-bHHoh0Yxrgrw91dPAmVnzKleA8HiVMeHy6xYqOVRw3LuZ2hDOwUiQVCbrzPZckO5S30F1Yym8zLCINN0HfcM3w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -484,12 +544,18 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zundo": ["zundo@2.3.0", "", { "peerDependencies": { "zustand": "^4.3.0 || ^5.0.0" } }, "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ=="], "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "@pascal-app/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@pascal-app/editor/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], "browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], diff --git a/package.json b/package.json index be90476..244a68b 100644 --- a/package.json +++ b/package.json @@ -29,19 +29,19 @@ "urdf-loader": "^0.13.1" }, "peerDependencies": { - "@pascal-app/core": ">=1.0.0-beta.4 <1.0.0", - "@pascal-app/editor": ">=1.0.0-beta.4 <1.0.0", - "@pascal-app/viewer": ">=1.0.0-beta.4 <1.0.0", + "@pascal-app/core": ">=1.0.0-beta.4 <2.0.0", + "@pascal-app/editor": ">=1.0.0-beta.4 <2.0.0", + "@pascal-app/viewer": ">=1.0.0-beta.4 <2.0.0", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.185 || ^0.186", "zod": "^4", "zustand": "^5" }, "devDependencies": { - "@pascal-app/core": "1.0.0-beta.4", - "@pascal-app/editor": "1.0.0-beta.4", - "@pascal-app/viewer": "1.0.0-beta.4", + "@pascal-app/core": "1.0.3", + "@pascal-app/editor": "1.0.3", + "@pascal-app/viewer": "1.0.3", "@react-three/fiber": "^9.5.0", "@types/bun": "^1.3.5", "@types/blob-stream": "^0.1.33", @@ -50,10 +50,10 @@ "@types/pdfkit": "^0.17.3", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@types/three": "^0.185.0", + "@types/three": "0.184.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "three": "^0.185.0", + "three": "0.186.0", "typescript": "6.0.3", "zod": "^4.3.6", "zustand": "^5.0.11" From ce89c7518d4f0afb9406ba033b3b33d0c579cb8c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:01:22 -0400 Subject: [PATCH 03/14] test: inspector slider drags commit one undo step, failing first Co-Authored-By: Claude Opus 5.5 (1M context) --- src/joint-controls.test.ts | 22 ++++++++ src/test-support/host-stubs.ts | 26 ++++++++++ src/test-support/joint-controls-probe.tsx | 61 +++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/joint-controls.test.ts create mode 100644 src/test-support/host-stubs.ts create mode 100644 src/test-support/joint-controls-probe.tsx diff --git a/src/joint-controls.test.ts b/src/joint-controls.test.ts new file mode 100644 index 0000000..f15bd92 --- /dev/null +++ b/src/joint-controls.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { runProbe } from './test-support/host-stubs' + +describe('Articraft inspector', () => { + test('every slider drag commits as one undo step (restoreOnCommit left on)', () => { + const sliders = runProbe>( + new URL('./test-support/joint-controls-probe.tsx', import.meta.url).pathname, + ) + expect(sliders.map((slider) => slider.label)).toEqual([ + 'Scale', + 'Position X', + 'Position Y', + 'Position Z', + 'Rotation X', + 'Rotation Y', + 'Rotation Z', + 'lid', + 'drawer', + ]) + expect(sliders.filter((slider) => slider.restoreOnCommit === false)).toEqual([]) + }) +}) diff --git a/src/test-support/host-stubs.ts b/src/test-support/host-stubs.ts new file mode 100644 index 0000000..5619cce --- /dev/null +++ b/src/test-support/host-stubs.ts @@ -0,0 +1,26 @@ +import { plugin } from 'bun' + +/** + * Replaces the host packages (`@pascal-app/core`, `editor`, `viewer`) with + * stubs for a probe script. Module stubs are process-global, so probes run in + * a child process (see `runProbe`) and never inside the test runner. + */ +export function stubHostModules(stubs: Record>): void { + plugin({ + name: 'articraft-host-stubs', + setup(build) { + for (const [specifier, exports] of Object.entries(stubs)) { + build.module(specifier, () => ({ exports, loader: 'object' })) + } + }, + }) +} + +/** Runs a probe script in a child process and returns the JSON it prints. */ +export function runProbe(probe: string): T { + const result = Bun.spawnSync([process.execPath, probe], { stderr: 'pipe', stdout: 'pipe' }) + if (result.exitCode !== 0) { + throw new Error(`${probe} failed:\n${result.stderr.toString()}`) + } + return JSON.parse(result.stdout.toString()) as T +} diff --git a/src/test-support/joint-controls-probe.tsx b/src/test-support/joint-controls-probe.tsx new file mode 100644 index 0000000..4cd7808 --- /dev/null +++ b/src/test-support/joint-controls-probe.tsx @@ -0,0 +1,61 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { stubHostModules } from './host-stubs' + +// Renders the Articraft inspector against stubbed host packages and prints +// the props every SliderControl received. +const NODE_ID = 'articraft_probe' +const node = { + id: NODE_ID, + type: 'articraft:asset', + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: 1, + joints: [ + { + name: 'lid', + type: 'revolute', + parent: 'base', + child: 'lid', + axis: [0, 0, 1], + origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] }, + limits: { lower: 0, upper: 1.5 }, + }, + { + name: 'drawer', + type: 'prismatic', + parent: 'base', + child: 'drawer', + axis: [1, 0, 0], + origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] }, + limits: { lower: 0, upper: 0.4 }, + }, + ], + jointValues: {}, + motionEnabled: false, + attribution: { creator: 'Articraft authors', license: 'CC-BY-4.0', sourceUrl: 'https://x.test' }, +} +const sliders: Array<{ label: string; restoreOnCommit: unknown }> = [] +const state = { nodes: { [NODE_ID]: node }, updateNode: () => undefined } + +stubHostModules({ + '@pascal-app/core': { + useScene: Object.assign((select: (value: typeof state) => unknown) => select(state), { + getState: () => state, + }), + }, + '@pascal-app/editor': { + PanelSection: ({ children }: { children?: unknown }) => children, + SliderControl: (props: { label: string; restoreOnCommit?: boolean }) => { + sliders.push({ label: props.label, restoreOnCommit: props.restoreOnCommit ?? 'default' }) + return null + }, + }, + '@pascal-app/viewer': { + useViewer: (select: (value: unknown) => unknown) => + select({ selection: { selectedIds: [NODE_ID] } }), + }, +}) + +const { default: ArticraftJointControls } = await import('../joint-controls') +renderToStaticMarkup() +console.log(JSON.stringify(sliders)) From 45f2e783f01761350955667a97a3be645937c44e Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:02:00 -0400 Subject: [PATCH 04/14] fix: inspector drags record one undo step Every slider passed restoreOnCommit={false}: SliderControl pauses history for the drag and, with the flag off, resumes without a tracked write, so a drag of scale, position, rotation or a joint left no undo step. The default restores the pre-drag value and writes the final one as a single tracked change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/joint-controls.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/joint-controls.tsx b/src/joint-controls.tsx index d67fe85..e249e4d 100644 --- a/src/joint-controls.tsx +++ b/src/joint-controls.tsx @@ -73,7 +73,6 @@ export default function ArticraftJointControls() { min={0.05} onChange={setScale} precision={2} - restoreOnCommit={false} step={0.05} value={node.scale} /> @@ -85,7 +84,6 @@ export default function ArticraftJointControls() { min={-100} onChange={(value) => setVector('position', index, value)} precision={2} - restoreOnCommit={false} step={0.05} unit="m" value={node.position[index]} @@ -99,7 +97,6 @@ export default function ArticraftJointControls() { min={-180} onChange={(value) => setVector('rotation', index, value)} precision={1} - restoreOnCommit={false} step={1} unit="°" value={toDegrees(node.rotation[index])} @@ -126,7 +123,6 @@ export default function ArticraftJointControls() { min={displayRange[0]} onChange={(value) => setJoint(joint, angular ? toRadians(value) : value)} precision={angular ? 1 : 3} - restoreOnCommit={false} step={angular ? 1 : 0.001} unit={angular ? '°' : 'm'} value={angular From 97952c82f79065900f8d43438154f66d3479662c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:02:43 -0400 Subject: [PATCH 05/14] test: continuous joints spin a full turn per loop, failing first Co-Authored-By: Claude Opus 5.5 (1M context) --- src/animation.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/motion.test.ts | 12 ++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/animation.test.ts b/src/animation.test.ts index b38ff20..39d27e3 100644 --- a/src/animation.test.ts +++ b/src/animation.test.ts @@ -89,6 +89,49 @@ describe('Articraft GLB animation export', () => { expect(fixedTarget.position.toArray()).toEqual([0, 0, 0]) }) + test('bakes a continuous joint as a full turn over the loop, seamless at the seam', () => { + const root = new Group() + const wheelTarget = new Group() + markArticraftJointTarget(wheelTarget, 'wheel_spin') + root.add(wheelTarget) + const node = ArticraftAssetNode.parse({ + id: 'articraft_spin_test', + catalogId: 'spin-test', + title: 'Spin test', + source: 'generated', + artifact: { format: 'usdz', url: 'https://assets.example/model.usdz' }, + joints: [ + { + name: 'wheel_spin', + type: 'continuous', + parent: 'base', + child: 'wheel', + axis: [0, 1, 0], + origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] }, + limits: null, + }, + ], + attribution: { + creator: 'Articraft authors', + license: 'CC-BY-4.0', + sourceUrl: 'https://example.com/source', + }, + }) + + const track = bakeArticraftAnimation(node, root)?.tracks[0] + expect(track?.ValueTypeName).toBe('quaternion') + const rest = new Quaternion() + const at = (sample: number) => + new Quaternion().fromArray(Array.from(track?.values.slice(sample * 4, sample * 4 + 4) ?? [])) + // 32 samples per loop: a quarter loop is a quarter turn, half a loop half a turn. + expect(at(8).angleTo(rest)).toBeCloseTo(Math.PI / 2, 6) + expect(at(16).angleTo(rest)).toBeCloseTo(Math.PI, 6) + expect(at(24).angleTo(rest)).toBeCloseTo(Math.PI / 2, 6) + expect(at(32).angleTo(rest)).toBeCloseTo(0, 6) + const quarter = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2) + expect(at(8).angleTo(quarter)).toBeCloseTo(0, 6) + }) + test('does not emit an animation without a resolved movable joint target', () => { const node = ArticraftAssetNode.parse({ id: 'articraft_static_test', diff --git a/src/motion.test.ts b/src/motion.test.ts index 97c4dc5..5cefacc 100644 --- a/src/motion.test.ts +++ b/src/motion.test.ts @@ -18,6 +18,18 @@ describe('persistent articulation motion', () => { expect(motionValueAtTime(hinge, 0, Math.PI / 2 / 1.4)).toBeCloseTo(1.5, 8) }) + test('spins a continuous joint one full turn per loop instead of rocking it', () => { + const wheel: ArticraftJoint = { ...hinge, type: 'continuous', limits: null } + const period = (Math.PI * 2) / 1.4 + const samples = Array.from({ length: 9 }, (_, step) => + motionValueAtTime(wheel, 0, (step / 8) * period), + ) + for (const [step, value] of samples.entries()) { + expect(value).toBeCloseTo((step / 8) * Math.PI * 2, 8) + } + expect(motionValueAtTime(wheel, 0, period * 3)).toBeCloseTo(Math.PI * 6, 8) + }) + test('provides a bounded fallback for joints without limits', () => { expect(motionRange({ ...hinge, type: 'continuous', limits: null })).toEqual([ -Math.PI, From cdbc0b14338c1b15f8d3b40746d66c060af73b8b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:03:58 -0400 Subject: [PATCH 06/14] fix: continuous joints spin instead of rocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuous joints (wheels, fans, knobs: 20.8 % of the catalog's movable joints) followed the same sine sweep as limited ones, over a fallback range of ±π, so they rocked back and forth. They now turn one full revolution per motion period, live and in the exported loop clip, which stays seamless. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/animation.test.ts | 2 +- src/animation.ts | 7 +++---- src/motion.ts | 9 ++++++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/animation.test.ts b/src/animation.test.ts index 39d27e3..8d98243 100644 --- a/src/animation.test.ts +++ b/src/animation.test.ts @@ -129,7 +129,7 @@ describe('Articraft GLB animation export', () => { expect(at(24).angleTo(rest)).toBeCloseTo(Math.PI / 2, 6) expect(at(32).angleTo(rest)).toBeCloseTo(0, 6) const quarter = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2) - expect(at(8).angleTo(quarter)).toBeCloseTo(0, 6) + expect(at(8).angleTo(quarter)).toBeLessThan(1e-3) }) test('does not emit an animation without a resolved movable joint target', () => { diff --git a/src/animation.ts b/src/animation.ts index fd2901c..eecdf1e 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,12 +6,11 @@ import { Vector3, VectorKeyframeTrack, } from 'three' -import { motionValueAtTime } from './motion' +import { MOTION_PERIOD_SECONDS, motionValueAtTime } from './motion' import type { ArticraftAssetNode } from './schema' import type { ArticraftJoint } from './types' const JOINT_TARGET_KEY = '__articraftJointTarget' -const MOTION_DURATION_SECONDS = (Math.PI * 2) / 1.4 const MOTION_SAMPLES = 32 type JointTargetMarker = { @@ -36,7 +35,7 @@ export function bakeArticraftAnimation( const tracks = [] as Array const times = Array.from( { length: MOTION_SAMPLES + 1 }, - (_, step) => (step / MOTION_SAMPLES) * MOTION_DURATION_SECONDS, + (_, step) => (step / MOTION_SAMPLES) * MOTION_PERIOD_SECONDS, ) let motionIndex = 0 @@ -66,7 +65,7 @@ export function bakeArticraftAnimation( } if (tracks.length === 0) return null - const clip = new AnimationClip(`${node.id}: loop`, MOTION_DURATION_SECONDS, tracks) + const clip = new AnimationClip(`${node.id}: loop`, MOTION_PERIOD_SECONDS, tracks) clip.userData = { loop: true } return clip } diff --git a/src/motion.ts b/src/motion.ts index 04b9c4d..bfa13cf 100644 --- a/src/motion.ts +++ b/src/motion.ts @@ -39,6 +39,11 @@ export function usePrefersReducedMotion(): boolean { ) } +/** Motion rate in radians per second: the sweep's angular frequency, and how fast continuous joints turn. */ +export const MOTION_RATE = 1.4 +/** One sweep of the limited joints, and one full turn of the continuous ones. */ +export const MOTION_PERIOD_SECONDS = (Math.PI * 2) / MOTION_RATE + export function motionRange(joint: ArticraftJoint): [number, number] { if (joint.limits && joint.limits.upper > joint.limits.lower) { return [joint.limits.lower, joint.limits.upper] @@ -51,6 +56,8 @@ export function motionValueAtTime( index: number, elapsedSeconds: number, ): number { + // A wheel, fan or knob turns without end; sweeping it back and forth over ±π rocks it instead. + if (joint.type === 'continuous') return elapsedSeconds * MOTION_RATE const hasLimits = joint.limits && joint.limits.upper > joint.limits.lower const lower = hasLimits ? joint.limits!.lower @@ -62,6 +69,6 @@ export function motionValueAtTime( : joint.type === 'prismatic' ? 1 : Math.PI - const progress = (Math.sin(elapsedSeconds * 1.4 + index * 0.7) + 1) / 2 + const progress = (Math.sin(elapsedSeconds * MOTION_RATE + index * 0.7) + 1) / 2 return lower + (upper - lower) * progress } From 93a3e10dee7e8de40e4d33fe621b121a8e069e88 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:05:16 -0400 Subject: [PATCH 07/14] test: placement saves the rest pose and no playback state, failing first Co-Authored-By: Claude Opus 5.5 (1M context) --- src/catalog-node.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ src/motion-state.test.ts | 15 ++++++++++++++ src/schema.test.ts | 6 +++--- 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/catalog-node.test.ts create mode 100644 src/motion-state.test.ts diff --git a/src/catalog-node.test.ts b/src/catalog-node.test.ts new file mode 100644 index 0000000..e8a3634 --- /dev/null +++ b/src/catalog-node.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { catalogNode } from './catalog-node' +import type { ArticraftCatalogItem } from './types' + +const item: ArticraftCatalogItem = { + id: 'articraft-10k:lamp', + title: 'Task lamp', + source: 'articraft-10k', + artifact: { format: 'urdf', url: 'https://assets.example/model.urdf' }, + dimensions: [0.3, 0.6, 0.3], + parts: [{ name: 'base' }, { name: 'arm' }], + joints: [ + { + name: 'arm_hinge', + type: 'revolute', + parent: 'base', + child: 'arm', + axis: [0, 1, 0], + origin: { xyz: [0, 0, 0.1], rpy: [0, 0, 0] }, + limits: { lower: 0, upper: 1.2 }, + }, + ], + defaultJointValues: { arm_hinge: 0 }, + attribution: { + creator: 'Articraft authors', + license: 'CC-BY-4.0', + sourceUrl: 'https://huggingface.co/datasets/camvsl/Articraft-10K', + }, +} + +describe('placing a catalog asset', () => { + test('saves its rest pose and no playback state', () => { + const node = catalogNode(item, [1, 0, 2]) + expect(node).toMatchObject({ + type: 'articraft:asset', + position: [1, 0, 2], + catalogId: item.id, + jointValues: { arm_hinge: 0 }, + }) + expect(Object.hasOwn(node, 'motionEnabled')).toBe(false) + }) +}) diff --git a/src/motion-state.test.ts b/src/motion-state.test.ts new file mode 100644 index 0000000..186ea15 --- /dev/null +++ b/src/motion-state.test.ts @@ -0,0 +1,15 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { articraftMotionPlaying, setArticraftMotionPlaying, useArticraftMotion } from './motion-state' + +describe('Articraft playback state', () => { + beforeEach(() => useArticraftMotion.setState({ playing: {} })) + + test('is stopped until started, per asset', () => { + expect(articraftMotionPlaying('articraft_a')).toBe(false) + setArticraftMotionPlaying('articraft_a', true) + expect(articraftMotionPlaying('articraft_a')).toBe(true) + expect(articraftMotionPlaying('articraft_b')).toBe(false) + setArticraftMotionPlaying('articraft_a', false) + expect(useArticraftMotion.getState().playing).toEqual({}) + }) +}) diff --git a/src/schema.test.ts b/src/schema.test.ts index 7e87cff..d881ebf 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -36,11 +36,10 @@ describe('Articraft asset schema', () => { expect(node.type).toBe('articraft:asset') expect(node.jointValues.globe_spin).toBe(1.25) - expect(node.motionEnabled).toBe(true) expect(node.artifact.sha256).toBe('a'.repeat(64)) }) - test('keeps older nodes still until motion is explicitly enabled', () => { + test('drops the playback flag older scenes saved: playback is transient', () => { const node = ArticraftAssetNode.parse({ catalogId: 'legacy', title: 'Existing asset', @@ -51,8 +50,9 @@ describe('Articraft asset schema', () => { license: 'CC-BY-4.0', sourceUrl: 'https://huggingface.co/datasets/camvsl/Articraft-10K', }, + motionEnabled: true, }) - expect(node.motionEnabled).toBe(false) + expect(Object.hasOwn(node, 'motionEnabled')).toBe(false) }) }) From 63a872a5c01a35c99307528fc3c6e97fcd2d4a91 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:07:03 -0400 Subject: [PATCH 08/14] fix: motion starts off and is never saved Placement wrote motionEnabled: true and the inspector toggled it through updateNode, so every placed asset animated forever by default and Start/Stop went into undo, autosave and collaboration. Playback now lives in a transient per-asset store: assets start still, the scene keeps only the rest pose (jointValues), and stopping returns every joint to it. The saved flag is dropped from the schema; older scenes that saved it load still. The placement preview shows the rest pose too. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 17 ++++---- src/catalog-node.ts | 23 +++++++++++ src/definition.ts | 3 +- src/joint-controls.tsx | 29 ++++++-------- src/motion-state.ts | 24 ++++++++++++ src/renderer.tsx | 17 ++++---- src/schema.test.ts | 1 - src/schema.ts | 1 - src/test-support/joint-controls-probe.tsx | 1 - src/tool.tsx | 48 ++--------------------- 10 files changed, 84 insertions(+), 80 deletions(-) create mode 100644 src/catalog-node.ts create mode 100644 src/motion-state.ts diff --git a/README.md b/README.md index 5887c53..5fce372 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ plugin communicates only through the documented public plugin contracts and same-origin host routes described below. Placement previews use the same articulated URDF/USDZ hierarchy as committed -nodes, with translucent non-interactive materials and live articulation. New -placements keep that motion active after commit and after selection changes. -The inspector can stop or restart motion without writing per-frame joint values -into scene history or autosave. Automatic motion is paused when the operating -system requests reduced motion. +nodes, with translucent non-interactive materials, shown in their rest pose. +Placed assets start still. The inspector starts and stops their motion; playing +is transient, like Pascal's own mechanisms: it is never written to the scene, +scene history, autosave or collaboration, and a reload shows the saved rest +pose. Motion is paused when the operating system requests reduced motion. This is a Pascal integration maintained by `pascalorg`. It is not an official hosted service from the Articraft researchers. @@ -40,8 +40,8 @@ hosted service from the Articraft researchers. without plugin-specific quality toggles. - Live position, rotation, scale, and joint controls in the Pascal inspector; radians stay radians in scene data and are displayed as degrees only for people. -- Persistent, selection-independent motion that smoothly sweeps every movable - joint while keeping per-frame values transient. +- Transient, selection-independent motion: limited joints sweep their range and + continuous joints turn. - Native looping glTF animation clips for those articulated joints when a Pascal project is exported to GLB; the saved joint pose remains the file's rest pose. - A registry-native 2D footprint that follows each asset's persisted position, @@ -71,7 +71,8 @@ credentials. Hosts expose the same-origin broker described in provider may retain inputs under its own policy. - A placed Pascal node persists the catalog or generated artifact URL and digest, title, source attribution, optional generation prompt, dimensions, - part/joint graph, transform, current joint pose, and whether motion is enabled. + part/joint graph, transform and rest joint pose. Whether motion is playing is + not saved. It does not persist a provider key, worker bearer token, Supabase service-role key, or Pascal user identity. diff --git a/src/catalog-node.ts b/src/catalog-node.ts new file mode 100644 index 0000000..6e56414 --- /dev/null +++ b/src/catalog-node.ts @@ -0,0 +1,23 @@ +import { ArticraftAssetNode } from './schema' +import type { ArticraftCatalogItem, Vec3 } from './types' + +/** The node placing a catalog item creates: its artifact, joint graph and rest pose. */ +export function catalogNode(item: ArticraftCatalogItem, position: Vec3): ArticraftAssetNode { + return ArticraftAssetNode.parse({ + name: item.title, + position, + rotation: [0, 0, 0], + scale: 1, + catalogId: item.id, + title: item.title, + source: item.source, + artifact: item.artifact, + thumbnailUrl: item.thumbnailUrl, + dimensions: item.dimensions, + parts: item.parts, + joints: item.joints, + jointValues: item.defaultJointValues, + attribution: item.attribution, + prompt: item.prompt, + }) +} diff --git a/src/definition.ts b/src/definition.ts index 827e800..80554d4 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -74,7 +74,6 @@ export const articraftAssetDefinition: Definition = { parts: [], joints: [], jointValues: {}, - motionEnabled: false, attribution: { creator: 'Articraft authors', license: 'CC-BY-4.0', @@ -125,6 +124,6 @@ export const articraftAssetDefinition: Definition = { }, mcp: { description: - 'A poseable Articraft asset. Stores an immutable artifact, rigid-part joint graph, per-joint revolute/continuous/prismatic pose values, and persistent motion state.', + 'A poseable Articraft asset. Stores an immutable artifact, rigid-part joint graph and per-joint revolute/continuous/prismatic rest pose values. Playing its motion is transient and never saved.', }, } diff --git a/src/joint-controls.tsx b/src/joint-controls.tsx index e249e4d..187a133 100644 --- a/src/joint-controls.tsx +++ b/src/joint-controls.tsx @@ -5,6 +5,7 @@ import { PanelSection, SliderControl } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { usePrefersReducedMotion } from './motion' +import { setArticraftMotionPlaying, useArticraftMotion } from './motion-state' import type { ArticraftAssetNode } from './schema' import type { ArticraftJoint } from './types' @@ -18,6 +19,9 @@ export default function ArticraftJointControls() { ? (value as unknown as ArticraftAssetNode) : null }) + const playing = useArticraftMotion((state) => + node ? state.playing[node.id] === true : false, + ) const reducedMotion = usePrefersReducedMotion() const movable = useMemo( () => node?.joints.filter((joint) => joint.type !== 'fixed') ?? [], @@ -26,27 +30,20 @@ export default function ArticraftJointControls() { if (!node) return null - const setMotionEnabled = (motionEnabled: boolean) => { - useScene.getState().updateNode(node.id as AnyNodeId, { motionEnabled } as never) - } - + // Posing a joint stops playback so the pose being edited is the one shown. const setJoint = (joint: ArticraftJoint, value: number) => { + setArticraftMotionPlaying(node.id, false) useScene.getState().updateNode( node.id as AnyNodeId, - { - motionEnabled: false, - jointValues: { ...node.jointValues, [joint.name]: value }, - } as never, + { jointValues: { ...node.jointValues, [joint.name]: value } } as never, ) } const reset = () => { + setArticraftMotionPlaying(node.id, false) useScene.getState().updateNode( node.id as AnyNodeId, - { - motionEnabled: false, - jointValues: Object.fromEntries(movable.map((joint) => [joint.name, 0])), - } as never, + { jointValues: Object.fromEntries(movable.map((joint) => [joint.name, 0])) } as never, ) } @@ -135,15 +132,15 @@ export default function ArticraftJointControls() { {movable.length > 0 && (
diff --git a/src/motion-state.ts b/src/motion-state.ts new file mode 100644 index 0000000..1077e65 --- /dev/null +++ b/src/motion-state.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' + +/** + * Which placed assets are playing their motion. Playback is transient, like + * Pascal's own mechanisms: never saved, undone or shared. The scene keeps only + * each asset's rest pose (`jointValues`). + */ +export const useArticraftMotion = create<{ playing: Record }>(() => ({ + playing: {}, +})) + +export function articraftMotionPlaying(nodeId: string): boolean { + return useArticraftMotion.getState().playing[nodeId] === true +} + +export function setArticraftMotionPlaying(nodeId: string, on: boolean): void { + useArticraftMotion.setState((state) => { + if ((state.playing[nodeId] === true) === on) return state + const playing = { ...state.playing } + if (on) playing[nodeId] = true + else delete playing[nodeId] + return { playing } + }) +} diff --git a/src/renderer.tsx b/src/renderer.tsx index c0c7dd0..bb0cf0d 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -29,6 +29,7 @@ import URDFLoader from 'urdf-loader' import { markArticraftJointTarget } from './animation' import { createArticraftAppearanceController } from './appearance' import { motionValueAtTime, usePrefersReducedMotion } from './motion' +import { setArticraftMotionPlaying, useArticraftMotion } from './motion-state' import type { ArticraftAssetNode } from './schema' import { resolveArticraftRootTransform } from './transform' import type { ArticraftJoint } from './types' @@ -49,6 +50,8 @@ export default function ArticraftRenderer({ node }: { node: ArticraftAssetNode } const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) const transform = resolveArticraftRootTransform(node, liveTransform, liveOverride) useRegistry(node.id, node.type, ref as React.RefObject) + // Playback is transient: it ends with the asset rather than outliving it. + useEffect(() => () => setArticraftMotionPlaying(node.id, false), [node.id]) return ( (null) @@ -81,8 +82,9 @@ export function ArticraftVisual({ const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) const sceneTheme = useViewer((state) => state.sceneTheme) + const playing = useArticraftMotion((state) => !ghost && state.playing[node.id] === true) const shouldAnimate = - (motionEnabled ?? node.motionEnabled) && + playing && !reducedMotion && node.joints.some((joint) => joint.type !== 'fixed') @@ -141,13 +143,14 @@ export function ArticraftVisual({ invalidate() }, [colorPreset, ghost, invalidate, loaded, sceneTheme, shading, textures]) + // The rest pose, and where every joint returns when playback stops. useEffect(() => { - if (!loaded) return - for (const [name, value] of Object.entries(node.jointValues)) { - loaded.setJointValue(name, value) + if (!loaded || shouldAnimate) return + for (const joint of node.joints) { + if (joint.type !== 'fixed') loaded.setJointValue(joint.name, node.jointValues[joint.name] ?? 0) } invalidate() - }, [invalidate, loaded, node.jointValues, shouldAnimate]) + }, [invalidate, loaded, node.jointValues, node.joints, shouldAnimate]) useFrame((state) => { if (!(loaded && shouldAnimate)) return diff --git a/src/schema.test.ts b/src/schema.test.ts index d881ebf..3ac3599 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -26,7 +26,6 @@ describe('Articraft asset schema', () => { }, ], jointValues: { globe_spin: 1.25 }, - motionEnabled: true, attribution: { creator: 'Articraft authors', license: 'CC-BY-4.0', diff --git a/src/schema.ts b/src/schema.ts index 970f4f1..d1b37a4 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -48,7 +48,6 @@ export const ArticraftAssetNode = BaseNode.extend({ .default([]), joints: z.array(ArticraftJoint).default([]), jointValues: z.record(z.string(), z.number()).default({}), - motionEnabled: z.boolean().default(false), attribution: z.object({ creator: z.string(), license: z.string(), diff --git a/src/test-support/joint-controls-probe.tsx b/src/test-support/joint-controls-probe.tsx index 4cd7808..eaf8bf7 100644 --- a/src/test-support/joint-controls-probe.tsx +++ b/src/test-support/joint-controls-probe.tsx @@ -31,7 +31,6 @@ const node = { }, ], jointValues: {}, - motionEnabled: false, attribution: { creator: 'Articraft authors', license: 'CC-BY-4.0', sourceUrl: 'https://x.test' }, } const sliders: Array<{ label: string; restoreOnCommit: unknown }> = [] diff --git a/src/tool.tsx b/src/tool.tsx index 4ab685b..302c509 100644 --- a/src/tool.tsx +++ b/src/tool.tsx @@ -4,58 +4,18 @@ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' +import { catalogNode } from './catalog-node' import { ArticraftVisual } from './renderer' -import { ArticraftAssetNode } from './schema' import { useArticraftPlacement } from './placement' import { useArticraftStore } from './store' export default function ArticraftTool() { const activeLevelId = useViewer((state) => state.selection.levelId) const item = useArticraftStore((state) => state.selectedItem) - const previewNode = useMemo( - () => - item - ? ArticraftAssetNode.parse({ - name: item.title, - position: [0, 0, 0], - rotation: [0, 0, 0], - scale: 1, - catalogId: item.id, - title: item.title, - source: item.source, - artifact: item.artifact, - thumbnailUrl: item.thumbnailUrl, - dimensions: item.dimensions, - parts: item.parts, - joints: item.joints, - jointValues: item.defaultJointValues, - motionEnabled: true, - attribution: item.attribution, - prompt: item.prompt, - }) - : null, - [item], - ) + const previewNode = useMemo(() => (item ? catalogNode(item, [0, 0, 0]) : null), [item]) const { cursorRef, cursorVisible } = useArticraftPlacement(activeLevelId, (position) => { if (!(activeLevelId && item)) return - const node = ArticraftAssetNode.parse({ - name: item.title, - position, - rotation: [0, 0, 0], - scale: 1, - catalogId: item.id, - title: item.title, - source: item.source, - artifact: item.artifact, - thumbnailUrl: item.thumbnailUrl, - dimensions: item.dimensions, - parts: item.parts, - joints: item.joints, - jointValues: item.defaultJointValues, - motionEnabled: true, - attribution: item.attribution, - prompt: item.prompt, - }) + const node = catalogNode(item, position) useScene.getState().createNode(node as unknown as AnyNode, activeLevelId as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id as AnyNodeId] }) triggerSFX('sfx:item-place') @@ -65,7 +25,7 @@ export default function ArticraftTool() { return ( - + ) } From e1b34bee079089770b4f0ee506ce1b524b7a01e2 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:09:04 -0400 Subject: [PATCH 09/14] test: true rest-pose bounds and a bottom-centre offset, failing first Co-Authored-By: Claude Opus 5.5 (1M context) --- src/bounds.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++ src/catalog-node.test.ts | 7 ++++ 2 files changed, 80 insertions(+) create mode 100644 src/bounds.test.ts diff --git a/src/bounds.test.ts b/src/bounds.test.ts new file mode 100644 index 0000000..175e1bb --- /dev/null +++ b/src/bounds.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { bottomCentreFrame, objExtent, urdfRestBounds } from './bounds' + +// A 1 × 0.5 × 0.2 m base standing on z = 0, a lid hinged on its top edge +// (joint origin rotated a quarter turn about Z) carrying a Z-up cylinder, and +// a handle modelled in millimetres (mesh scale 0.001). +const URDF = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +const handle = objExtent('# handle\nv 0 -10 0\nv 100 10 20\nvn 0 0 1\nf 1 2 1\n') + +describe('rest-pose bounds', () => { + test('reads an OBJ vertex extent', () => { + expect(handle).toEqual({ min: [0, -10, 0], max: [100, 10, 20] }) + expect(objExtent('# no vertices\n')).toBeNull() + }) + + test('counts every visual with link, joint and visual origins and mesh scale, Y up', async () => { + const bounds = await urdfRestBounds(URDF, async (filename) => + filename === 'assets/handle.obj' ? handle : null, + ) + // URDF frame: base x ±0.5, y ±0.25, z 0..0.2. The lid frame sits at + // (0.2, 0, 0.2) turned 90° about Z: its cylinder spans x 0.1..0.3, + // y -0.1..0.1, z 0.2..0.6; its handle (0.3 m along lid x = world y) + // spans x 0.19..0.21, y 0.3..0.4, z 0.2..0.22. + // Pascal frame (x, z, -y): x -0.5..0.5, y 0..0.6, z -0.4..0.25. + expect(bounds).not.toBeNull() + const expected = { min: [-0.5, 0, -0.4], max: [0.5, 0.6, 0.25] } + for (const key of ['min', 'max'] as const) { + for (const axis of [0, 1, 2]) { + expect(bounds?.[key][axis]).toBeCloseTo(expected[key][axis] ?? Number.NaN, 9) + } + } + }) + + test('returns null when nothing is visible', async () => { + expect(await urdfRestBounds('', async () => null)).toBeNull() + }) + + test('stands an asset that extends below its origin on its bottom centre', () => { + const frame = bottomCentreFrame({ min: [-0.2, -0.35, 0.1], max: [0.6, 0.45, 0.5] }) + expect(frame.dimensions).toEqual([0.8, 0.8, 0.4]) + expect(frame.offset).toEqual([-0.2, 0.35, -0.3]) + }) +}) diff --git a/src/catalog-node.test.ts b/src/catalog-node.test.ts index e8a3634..25fac8b 100644 --- a/src/catalog-node.test.ts +++ b/src/catalog-node.test.ts @@ -39,4 +39,11 @@ describe('placing a catalog asset', () => { }) expect(Object.hasOwn(node, 'motionEnabled')).toBe(false) }) + + test('keeps the catalog footprint and the offset that stands it on the floor', () => { + const node = catalogNode({ ...item, offset: [0.01, 0.12, -0.02] }, [0, 0, 0]) + expect(node.dimensions).toEqual([0.3, 0.6, 0.3]) + expect(node.offset).toEqual([0.01, 0.12, -0.02]) + expect(catalogNode(item, [0, 0, 0]).offset).toBeUndefined() + }) }) From e86d4ff9d7483917f7f65d57a2136046e0fe246b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 21:12:21 -0400 Subject: [PATCH 10/14] fix: true rest-pose bounds, standing on the bottom centre Catalog dimensions came from raw OBJ vertices only, so primitive-only records fell back to [1, 1, 1] and the rest ignored primitives, origins and mesh scale; assets whose rest pose extends below their origin sank into the floor. urdfRestBounds computes the rest-pose bounds from every visual (box, cylinder, sphere, mesh) through link, joint and visual origins and mesh scale, in Pascal's Y-up frame; bottomCentreFrame turns them into dimensions and an offset. Nodes carry the catalog offset, and the renderer moves the loaded asset by it, so the asset stands on the floor centred on its footprint, like the placeholder box. Nodes without an offset render as before. On the audit's 200-record sample (seed 20260926): dimensions off by more than 10 % 188 -> 0 (max 0.3 % against an independent reference), sinking into the floor 48 -> 0, footprint centre off by more than 10 % 67 -> 0. Co-Authored-By: Claude Opus 5.5 (1M context) --- HOST_INTEGRATION.md | 9 +- src/bounds.ts | 227 ++++++++++++++++++++++++++++++++++++++++++++ src/catalog-node.ts | 1 + src/index.ts | 2 + src/renderer.tsx | 8 +- src/schema.ts | 2 + src/types.ts | 6 ++ 7 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/bounds.ts diff --git a/HOST_INTEGRATION.md b/HOST_INTEGRATION.md index 0861c29..578cb56 100644 --- a/HOST_INTEGRATION.md +++ b/HOST_INTEGRATION.md @@ -46,7 +46,14 @@ GET /api/plugins/articraft/generations/:jobId POST /api/plugins/articraft/generations/:jobId/cancel ``` -Catalog responses use `ArticraftCatalogResponse`. Generation creation accepts +Catalog responses use `ArticraftCatalogResponse`. Each item's `dimensions` is its +rest-pose size in Pascal's frame (Y up, metres) and its optional `offset` stands +the asset on its bottom centre at the node origin; placement copies both onto the +node. A host that syncs a catalog computes them with `urdfRestBounds` (every +visual through its link, joint and visual origins and mesh scale) and +`bottomCentreFrame`, both exported by the package. + +Generation creation accepts multipart form data (`prompt`, optional `image`, optional `provider`, optional `model`) and returns `{ id, status }`. Configuration returns the worker-selected default plus its allowlisted `models`; OpenAI/GPT-5.6 remains selected when those diff --git a/src/bounds.ts b/src/bounds.ts new file mode 100644 index 0000000..5982320 --- /dev/null +++ b/src/bounds.ts @@ -0,0 +1,227 @@ +import type { Vec3 } from './types' + +/** Axis-aligned bounds in the frame Pascal renders an asset in: Y up, metres. */ +export type Bounds = { min: Vec3; max: Vec3 } + +/** Where a mesh's vertices lie in its own frame, before the visual's `scale`. */ +export type MeshExtent = { min: Vec3; max: Vec3 } + +type XmlElement = { name: string; attributes: Record; children: XmlElement[] } +type Matrix = number[] // 4 × 4, row-major + +const IDENTITY: Matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + +/** + * The rest-pose bounds of a URDF (every joint at 0) in Pascal's frame: the + * URDF's Z-up axes turned Y-up, as the renderer turns the loaded robot + * (`rotation.x = -π/2`). Every visual counts (box, cylinder, sphere, mesh) + * through its link, joint and visual origins and the mesh `scale`; a + * rotated visual contributes the bounds of its turned box. `meshExtent` + * resolves a mesh `filename` as written in the URDF. Null when nothing is + * visible. + */ +export async function urdfRestBounds( + urdf: string, + meshExtent: (filename: string) => Promise, +): Promise { + const robot = parseXml(urdf).find((element) => element.name === 'robot') + if (!robot) return null + const links = robot.children.filter((element) => element.name === 'link') + const jointByChild = new Map() + for (const joint of robot.children.filter((element) => element.name === 'joint')) { + const child = childNamed(joint, 'child')?.attributes.link + if (child) jointByChild.set(child, joint) + } + const worldByLink = new Map() + const world = (link: string, visiting = new Set()): Matrix => { + const known = worldByLink.get(link) + if (known) return known + const joint = jointByChild.get(link) + const parent = joint && childNamed(joint, 'parent')?.attributes.link + // A root link, or a cycle the URDF should not have, sits at the origin. + const matrix = + joint && parent && !visiting.has(parent) + ? multiply(world(parent, visiting.add(link)), originOf(joint)) + : IDENTITY + worldByLink.set(link, matrix) + return matrix + } + + const min: Vec3 = [Infinity, Infinity, Infinity] + const max: Vec3 = [-Infinity, -Infinity, -Infinity] + for (const link of links) { + const linkWorld = world(link.attributes.name ?? '') + for (const visual of link.children.filter((element) => element.name === 'visual')) { + const extent = await visualExtent(visual, meshExtent) + if (!extent) continue + const matrix = multiply(linkWorld, originOf(visual)) + for (const x of [extent.min[0], extent.max[0]]) { + for (const y of [extent.min[1], extent.max[1]]) { + for (const z of [extent.min[2], extent.max[2]]) { + const point = transform(matrix, [x, y, z]) + for (const axis of [0, 1, 2] as const) { + min[axis] = Math.min(min[axis], point[axis]) + max[axis] = Math.max(max[axis], point[axis]) + } + } + } + } + } + } + if (!min.every(Number.isFinite)) return null + // URDF (x, y, z) Z up → Pascal (x, z, -y) Y up. + return { min: [min[0], min[2], -max[1]], max: [max[0], max[2], -min[1]] } +} + +/** + * The footprint and the offset that stand an asset on its bottom centre at the + * node's origin: the renderer moves the loaded asset by `offset` (before the + * node's scale), so its rest pose rests on the floor, centred on the node, + * like the placeholder box. Rounded to 0.1 mm. + */ +export function bottomCentreFrame(bounds: Bounds): { dimensions: Vec3; offset: Vec3 } { + const [minX, minY, minZ] = bounds.min + const [maxX, maxY, maxZ] = bounds.max + return { + dimensions: [ + round(Math.max(maxX - minX, 0.001)), + round(Math.max(maxY - minY, 0.001)), + round(Math.max(maxZ - minZ, 0.001)), + ], + offset: [round(-(minX + maxX) / 2), round(-minY), round(-(minZ + maxZ) / 2)], + } +} + +/** The extent of an OBJ's `v x y z` vertices, null when it has none. */ +export function objExtent(obj: string): MeshExtent | null { + const min: Vec3 = [Infinity, Infinity, Infinity] + const max: Vec3 = [-Infinity, -Infinity, -Infinity] + for (const line of obj.split('\n')) { + if (!line.startsWith('v ')) continue + const values = line.trim().split(/\s+/).slice(1, 4).map(Number) + if (values.length !== 3 || !values.every(Number.isFinite)) continue + for (const axis of [0, 1, 2] as const) { + min[axis] = Math.min(min[axis], values[axis] as number) + max[axis] = Math.max(max[axis], values[axis] as number) + } + } + return min.every(Number.isFinite) ? { min, max } : null +} + +async function visualExtent( + visual: XmlElement, + meshExtent: (filename: string) => Promise, +): Promise { + const shape = childNamed(visual, 'geometry')?.children[0] + if (!shape) return null + const number = (name: string) => Number(shape.attributes[name]) + if (shape.name === 'box') { + const [x, y, z] = vector(shape.attributes.size, [0, 0, 0]) + return { min: [-x / 2, -y / 2, -z / 2], max: [x / 2, y / 2, z / 2] } + } + if (shape.name === 'cylinder') { + const radius = number('radius') + const half = number('length') / 2 + if (!(Number.isFinite(radius) && Number.isFinite(half))) return null + return { min: [-radius, -radius, -half], max: [radius, radius, half] } + } + if (shape.name === 'sphere') { + const radius = number('radius') + return Number.isFinite(radius) + ? { min: [-radius, -radius, -radius], max: [radius, radius, radius] } + : null + } + if (shape.name === 'mesh' && shape.attributes.filename) { + const extent = await meshExtent(shape.attributes.filename) + if (!extent) return null + const scale = vector(shape.attributes.scale, [1, 1, 1]) + const a = extent.min.map((value, axis) => value * (scale[axis] ?? 1)) as Vec3 + const b = extent.max.map((value, axis) => value * (scale[axis] ?? 1)) as Vec3 + return { + min: [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])], + max: [Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])], + } + } + return null +} + +/** A URDF `` (fixed-axis roll, pitch, yaw) as a matrix. */ +function originOf(element: XmlElement): Matrix { + const origin = childNamed(element, 'origin') + const [x, y, z] = vector(origin?.attributes.xyz, [0, 0, 0]) + const [roll, pitch, yaw] = vector(origin?.attributes.rpy, [0, 0, 0]) + const [cr, sr, cp, sp, cy, sy] = [ + Math.cos(roll), + Math.sin(roll), + Math.cos(pitch), + Math.sin(pitch), + Math.cos(yaw), + Math.sin(yaw), + ] + // R = Rz(yaw) · Ry(pitch) · Rx(roll) + return [ + cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, x, + sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, y, + -sp, cp * sr, cp * cr, z, + 0, 0, 0, 1, + ] +} + +function multiply(a: Matrix, b: Matrix): Matrix { + const out: Matrix = new Array(16).fill(0) + for (let row = 0; row < 4; row += 1) { + for (let column = 0; column < 4; column += 1) { + let sum = 0 + for (let k = 0; k < 4; k += 1) sum += (a[row * 4 + k] ?? 0) * (b[k * 4 + column] ?? 0) + out[row * 4 + column] = sum + } + } + return out +} + +function transform(m: Matrix, [x, y, z]: Vec3): Vec3 { + const at = (index: number) => m[index] ?? 0 + return [ + at(0) * x + at(1) * y + at(2) * z + at(3), + at(4) * x + at(5) * y + at(6) * z + at(7), + at(8) * x + at(9) * y + at(10) * z + at(11), + ] +} + +function vector(value: string | undefined, fallback: Vec3): Vec3 { + const parts = value?.trim().split(/\s+/).map(Number) + return parts?.length === 3 && parts.every(Number.isFinite) ? (parts as Vec3) : fallback +} + +function childNamed(element: XmlElement, name: string): XmlElement | undefined { + return element.children.find((child) => child.name === name) +} + +function round(value: number): number { + return Math.round(value * 10_000) / 10_000 + 0 +} + +/** Elements of a small XML document (URDF): declarations and comments skipped. */ +function parseXml(source: string): XmlElement[] { + const roots: XmlElement[] = [] + const stack: XmlElement[] = [] + const tag = /|<\?[\s\S]*?\?>||]*>|<\/\s*([\w:.-]+)\s*>|<([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g + for (const match of source.matchAll(tag)) { + const [, closing, name, rawAttributes, selfClosing] = match + if (closing) { + stack.pop() + continue + } + if (!name) continue + const attributes: Record = {} + for (const attribute of (rawAttributes ?? '').matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) { + attributes[attribute[1] as string] = attribute[2] ?? attribute[3] ?? '' + } + const element: XmlElement = { name, attributes, children: [] } + const parent = stack.at(-1) + if (parent) parent.children.push(element) + else roots.push(element) + if (!selfClosing) stack.push(element) + } + return roots +} diff --git a/src/catalog-node.ts b/src/catalog-node.ts index 6e56414..aa01fb5 100644 --- a/src/catalog-node.ts +++ b/src/catalog-node.ts @@ -14,6 +14,7 @@ export function catalogNode(item: ArticraftCatalogItem, position: Vec3): Articra artifact: item.artifact, thumbnailUrl: item.thumbnailUrl, dimensions: item.dimensions, + offset: item.offset, parts: item.parts, joints: item.joints, jointValues: item.defaultJointValues, diff --git a/src/index.ts b/src/index.ts index e764045..01c93ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,8 @@ export const articraftHostPanel: EditorHostPanel = { } export { ARTICRAFT_API_BASE, fetchCatalog } from './api' +export { bottomCentreFrame, objExtent, urdfRestBounds } from './bounds' +export type { Bounds, MeshExtent } from './bounds' export { articraftAssetDefinition } from './definition' export { ArticraftAssetNode } from './schema' export type { diff --git a/src/renderer.tsx b/src/renderer.tsx index bb0cf0d..18eff89 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -183,7 +183,13 @@ export function ArticraftVisual({ useEffect(() => () => fallbackMaterial.dispose(), [fallbackMaterial]) - if (loaded) return + if (loaded) { + return ( + + + + ) + } return ( From 74afe91201a0f505edf334d7c8893420931f3e84 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 22:23:42 -0400 Subject: [PATCH 11/14] test: USDZ assets keep the stage's up axis and units, failing first Moves the USDZ joint-tree builder into usd-hierarchy.ts unchanged so it can be tested without a renderer. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/tests/test_generation.py | 29 ++++++++++++ src/renderer.tsx | 82 +-------------------------------- src/usd-hierarchy.test.ts | 62 +++++++++++++++++++++++++ src/usd-hierarchy.ts | 79 +++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 80 deletions(-) create mode 100644 src/usd-hierarchy.test.ts create mode 100644 src/usd-hierarchy.ts diff --git a/server/tests/test_generation.py b/server/tests/test_generation.py index f408597..fa8f4ab 100644 --- a/server/tests/test_generation.py +++ b/server/tests/test_generation.py @@ -1,3 +1,5 @@ +import pytest + from articraft_worker.generation import _joints, _parts, _public_manifest, _title from articraft_worker.models import Artifact, CatalogItem @@ -46,3 +48,30 @@ def test_public_manifest_omits_generation_prompt() -> None: assert "prompt" not in manifest assert manifest["id"] == "generated:abc123" + + +def test_frame_is_in_pascal_axes_and_metres(tmp_path) -> None: + from pxr import Gf, Usd, UsdGeom + + from articraft_worker.generation import _frame + + # A Z-up stage in half-metre units holding one box: x 0.5..1.5, y 1..3, + # z 1..5 in stage units, as mini-articraft writes its stages (Z up). + path = tmp_path / "model.usda" + stage = Usd.Stage.CreateNew(str(path)) + UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) + UsdGeom.SetStageMetersPerUnit(stage, 0.5) + UsdGeom.Xform.Define(stage, "/root") + box = UsdGeom.Cube.Define(stage, "/root/box") + box.GetSizeAttr().Set(2.0) + box.AddTranslateOp().Set(Gf.Vec3d(1, 2, 3)) + box.AddScaleOp().Set(Gf.Vec3f(0.5, 1, 2)) + stage.GetRootLayer().Save() + + dimensions, offset = _frame(path) + + # Pascal is Y up in metres: (x, z, -y) x 0.5 spans x 0.25..0.75, + # y 0.5..2.5, z -1.5..-0.5. + assert dimensions == pytest.approx((0.5, 2.0, 1.0)) + # The offset stands that box on its bottom centre at the node origin. + assert offset == pytest.approx((-0.5, -0.5, 1.0)) diff --git a/src/renderer.tsx b/src/renderer.tsx index 18eff89..5c873b0 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -15,14 +15,7 @@ import { } from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { - Group, - type Material, - type Mesh, - type Object3D, - Quaternion, - Vector3, -} from 'three' +import { Group, type Material, type Mesh, type Object3D } from 'three' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' import { USDLoader } from 'three/examples/jsm/loaders/USDLoader.js' import URDFLoader from 'urdf-loader' @@ -32,12 +25,7 @@ import { motionValueAtTime, usePrefersReducedMotion } from './motion' import { setArticraftMotionPlaying, useArticraftMotion } from './motion-state' import type { ArticraftAssetNode } from './schema' import { resolveArticraftRootTransform } from './transform' -import type { ArticraftJoint } from './types' - -type LoadedArticulationSource = { - root: Object3D - setJointValue: (name: string, value: number) => void -} +import { buildUsdHierarchy, type LoadedArticulationSource } from './usd-hierarchy' type LoadedArticulation = LoadedArticulationSource & { appearance: ReturnType @@ -252,72 +240,6 @@ async function loadUsdz( return root } -function buildUsdHierarchy( - source: Group, - parts: ArticraftAssetNode['parts'], - joints: ArticraftAssetNode['joints'], -): LoadedArticulationSource { - if (parts.length === 0) { - return { root: source, setJointValue: () => undefined } - } - - const scope = source.getObjectByName('parts') ?? source - const partObjects = new Map() - for (const part of parts) { - const object = scope.getObjectByName(part.objectName ?? part.name) - if (!object) continue - object.removeFromParent() - object.position.set(0, 0, 0) - object.quaternion.identity() - object.scale.set(1, 1, 1) - partObjects.set(part.name, object) - } - - if (partObjects.size === 0) { - return { root: source, setJointValue: () => undefined } - } - - const root = new Group() - const motionGroups = new Map() - const children = new Set() - - for (const joint of joints) { - const parent = partObjects.get(joint.parent) - const child = partObjects.get(joint.child) - if (!(parent && child)) continue - const frame = new Group() - frame.position.fromArray(joint.origin.xyz) - frame.rotation.set(...joint.origin.rpy, 'XYZ') - const motion = new Group() - if (joint.type !== 'fixed') markArticraftJointTarget(motion, joint.name) - frame.add(motion) - motion.add(child) - parent.add(frame) - motionGroups.set(joint.name, { group: motion, joint }) - children.add(joint.child) - } - - for (const [name, object] of partObjects) { - if (!children.has(name)) root.add(object) - } - - return { - root, - setJointValue: (name, value) => { - const entry = motionGroups.get(name) - if (!entry || entry.joint.type === 'fixed') return - entry.group.position.set(0, 0, 0) - entry.group.quaternion.identity() - const axis = new Vector3(...entry.joint.axis).normalize() - if (entry.joint.type === 'prismatic') { - entry.group.position.copy(axis.multiplyScalar(value)) - } else { - entry.group.quaternion.copy(new Quaternion().setFromAxisAngle(axis, value)) - } - }, - } -} - function markShadows(root: Object3D) { root.traverse((object) => { const mesh = object as Mesh diff --git a/src/usd-hierarchy.test.ts b/src/usd-hierarchy.test.ts new file mode 100644 index 0000000..dedfbfb --- /dev/null +++ b/src/usd-hierarchy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { Euler, Group, Object3D, Quaternion, Vector3 } from 'three' +import { buildUsdHierarchy } from './usd-hierarchy' +import type { ArticraftJoint } from './types' + +// What three's USDLoader returns for a mini-articraft stage: a root group that +// carries the stage's Z-up turn and metersPerUnit scale, holding a `parts` scope. +function loadedStage(): Group { + const source = new Group() + source.rotation.x = -Math.PI / 2 + source.scale.setScalar(0.5) + const scope = new Group() + scope.name = 'parts' + for (const name of ['base_1', 'lid_1']) { + const part = new Object3D() + part.name = name + scope.add(part) + } + source.add(scope) + return source +} + +const parts = [ + { name: 'base', objectName: 'base_1' }, + { name: 'lid', objectName: 'lid_1' }, +] +const lift: ArticraftJoint = { + name: 'lift', + type: 'prismatic', + parent: 'base', + child: 'lid', + axis: [0, 0, 1], + origin: { xyz: [0, 0, 1], rpy: [0, 0, 0] }, + limits: { lower: 0, upper: 1 }, +} + +function worldPosition(root: Object3D, name: string): Vector3 { + root.updateMatrixWorld(true) + const object = root.getObjectByName(name) + return object ? object.getWorldPosition(new Vector3()) : new Vector3(Number.NaN) +} + +describe('USDZ joint tree', () => { + test("keeps the stage's up axis and unit scale", () => { + const { root } = buildUsdHierarchy(loadedStage(), parts, [lift]) + // 1 unit up the stage's Z is 0.5 m up Pascal's Y. + expect(worldPosition(root, 'lid_1').distanceTo(new Vector3(0, 0.5, 0))).toBeLessThan(1e-9) + }) + + test('turns joint frames by URDF roll, pitch, yaw like the URDF path', () => { + const rpy: [number, number, number] = [0.3, 0.4, 0.5] + const source = loadedStage() + source.rotation.set(0, 0, 0) + source.scale.setScalar(1) + const { root } = buildUsdHierarchy(source, parts, [{ ...lift, origin: { xyz: [0, 0, 0], rpy } }]) + root.updateMatrixWorld(true) + const lid = root.getObjectByName('lid_1') + const turned = lid?.getWorldQuaternion(new Quaternion()) + const expected = new Quaternion().setFromEuler(new Euler(...rpy, 'ZYX')) + expect(turned?.angleTo(expected)).toBeLessThan(1e-9) + }) +}) diff --git a/src/usd-hierarchy.ts b/src/usd-hierarchy.ts new file mode 100644 index 0000000..3e74156 --- /dev/null +++ b/src/usd-hierarchy.ts @@ -0,0 +1,79 @@ +import { Group, type Object3D, Quaternion, Vector3 } from 'three' +import { markArticraftJointTarget } from './animation' +import type { ArticraftAssetNode } from './schema' +import type { ArticraftJoint } from './types' + +export type LoadedArticulationSource = { + root: Object3D + setJointValue: (name: string, value: number) => void +} + +/** + * Rebuilds a mini-articraft USDZ as a joint tree: each part under its parent + * through the joint's frame and a motion group the joint value drives. + */ +export function buildUsdHierarchy( + source: Group, + parts: ArticraftAssetNode['parts'], + joints: ArticraftAssetNode['joints'], +): LoadedArticulationSource { + if (parts.length === 0) { + return { root: source, setJointValue: () => undefined } + } + + const scope = source.getObjectByName('parts') ?? source + const partObjects = new Map() + for (const part of parts) { + const object = scope.getObjectByName(part.objectName ?? part.name) + if (!object) continue + object.removeFromParent() + object.position.set(0, 0, 0) + object.quaternion.identity() + object.scale.set(1, 1, 1) + partObjects.set(part.name, object) + } + + if (partObjects.size === 0) { + return { root: source, setJointValue: () => undefined } + } + + const root = new Group() + const motionGroups = new Map() + const children = new Set() + + for (const joint of joints) { + const parent = partObjects.get(joint.parent) + const child = partObjects.get(joint.child) + if (!(parent && child)) continue + const frame = new Group() + frame.position.fromArray(joint.origin.xyz) + frame.rotation.set(...joint.origin.rpy, 'XYZ') + const motion = new Group() + if (joint.type !== 'fixed') markArticraftJointTarget(motion, joint.name) + frame.add(motion) + motion.add(child) + parent.add(frame) + motionGroups.set(joint.name, { group: motion, joint }) + children.add(joint.child) + } + + for (const [name, object] of partObjects) { + if (!children.has(name)) root.add(object) + } + + return { + root, + setJointValue: (name, value) => { + const entry = motionGroups.get(name) + if (!entry || entry.joint.type === 'fixed') return + entry.group.position.set(0, 0, 0) + entry.group.quaternion.identity() + const axis = new Vector3(...entry.joint.axis).normalize() + if (entry.joint.type === 'prismatic') { + entry.group.position.copy(axis.multiplyScalar(value)) + } else { + entry.group.quaternion.copy(new Quaternion().setFromAxisAngle(axis, value)) + } + }, + } +} From cdbca590f8b99164a0c13027249701e1cab39dab Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 22:25:08 -0400 Subject: [PATCH 12/14] fix: USDZ assets keep the stage's up axis and units three's USDLoader turns a Z-up stage to Y up and applies metersPerUnit on the group it returns; the joint-tree rebuild moved every part under a new group and dropped both, so generated assets with parts lay on their backs. The rebuilt root now keeps the loaded group's transform, and joint frames use URDF's fixed-axis roll, pitch, yaw (ZYX) like the URDF path instead of XYZ. The worker measured dimensions in stage axes and units, so Pascal read the stage's depth as height. It now reports size and a bottom-centre offset in Pascal's frame (Y up, metres), like the catalog. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/articraft_worker/generation.py | 34 ++++++++++++++++++++------- server/articraft_worker/models.py | 1 + src/catalog-node.test.ts | 2 ++ src/catalog-node.ts | 3 ++- src/usd-hierarchy.test.ts | 2 +- src/usd-hierarchy.ts | 8 ++++++- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/server/articraft_worker/generation.py b/server/articraft_worker/generation.py index 481bd80..e410e6d 100644 --- a/server/articraft_worker/generation.py +++ b/server/articraft_worker/generation.py @@ -41,12 +41,14 @@ async def generate_item( storage_path = f"generated/{job_id}/model.usdz" artifact_url, digest = await storage.upload_artifact(storage_path, artifact_path) joints = _joints(model_value.get("articulations")) + dimensions, offset = _frame(artifact_path) item = CatalogItem( id=f"generated:{job_id}", title=_title(prompt), description="Generated by mini-articraft through the Pascal Articraft worker.", artifact=Artifact(url=artifact_url, sha256=digest), - dimensions=_dimensions(artifact_path), + dimensions=dimensions, + offset=offset, parts=_parts(model_value.get("parts")), joints=joints, defaultJointValues={ @@ -125,18 +127,34 @@ def _joints(value: object) -> list[Joint]: return result -def _dimensions(path: Path) -> tuple[float, float, float]: +def _frame( + path: Path, +) -> tuple[tuple[float, float, float], tuple[float, float, float] | None]: + """Rest-pose size and bottom-centre offset in Pascal's frame (Y up, metres). + + Pascal loads the stage with its up axis turned to Y and metersPerUnit + applied, so a Z-up stage's (x, y, z) becomes (x, z, -y), scaled to metres. + """ stage = Usd.Stage.Open(str(path)) if stage is None: - return (1.0, 1.0, 1.0) + return (1.0, 1.0, 1.0), None cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_]) box = cache.ComputeWorldBound(stage.GetPseudoRoot()).ComputeAlignedRange() - size = box.GetSize() - return ( - max(float(size[0]), 0.01), - max(float(size[1]), 0.01), - max(float(size[2]), 0.01), + if box.IsEmpty(): + return (1.0, 1.0, 1.0), None + unit = float(UsdGeom.GetStageMetersPerUnit(stage)) + low = [float(value) * unit for value in box.GetMin()] + high = [float(value) * unit for value in box.GetMax()] + if UsdGeom.GetStageUpAxis(stage) == UsdGeom.Tokens.z: + low, high = [low[0], low[2], -high[1]], [high[0], high[2], -low[1]] + size = [max(round(high[axis] - low[axis], 4), 0.001) for axis in range(3)] + dimensions = (size[0], size[1], size[2]) + offset = ( + round(-(low[0] + high[0]) / 2, 4) + 0.0, + round(-low[1], 4) + 0.0, + round(-(low[2] + high[2]) / 2, 4) + 0.0, ) + return dimensions, offset def _vec3( diff --git a/server/articraft_worker/models.py b/server/articraft_worker/models.py index 5618acb..590da3d 100644 --- a/server/articraft_worker/models.py +++ b/server/articraft_worker/models.py @@ -49,6 +49,7 @@ class CatalogItem(BaseModel): source: Literal["generated"] = "generated" artifact: Artifact dimensions: tuple[float, float, float] + offset: tuple[float, float, float] | None = None parts: list[Part] joints: list[Joint] defaultJointValues: dict[str, float] diff --git a/src/catalog-node.test.ts b/src/catalog-node.test.ts index 25fac8b..11c51a5 100644 --- a/src/catalog-node.test.ts +++ b/src/catalog-node.test.ts @@ -45,5 +45,7 @@ describe('placing a catalog asset', () => { expect(node.dimensions).toEqual([0.3, 0.6, 0.3]) expect(node.offset).toEqual([0.01, 0.12, -0.02]) expect(catalogNode(item, [0, 0, 0]).offset).toBeUndefined() + const unmeasured = { ...item, offset: null } as unknown as ArticraftCatalogItem + expect(catalogNode(unmeasured, [0, 0, 0]).offset).toBeUndefined() }) }) diff --git a/src/catalog-node.ts b/src/catalog-node.ts index aa01fb5..cc6e446 100644 --- a/src/catalog-node.ts +++ b/src/catalog-node.ts @@ -14,7 +14,8 @@ export function catalogNode(item: ArticraftCatalogItem, position: Vec3): Articra artifact: item.artifact, thumbnailUrl: item.thumbnailUrl, dimensions: item.dimensions, - offset: item.offset, + // A worker that could not measure its stage reports null. + offset: item.offset ?? undefined, parts: item.parts, joints: item.joints, jointValues: item.defaultJointValues, diff --git a/src/usd-hierarchy.test.ts b/src/usd-hierarchy.test.ts index dedfbfb..2c7cef5 100644 --- a/src/usd-hierarchy.test.ts +++ b/src/usd-hierarchy.test.ts @@ -57,6 +57,6 @@ describe('USDZ joint tree', () => { const lid = root.getObjectByName('lid_1') const turned = lid?.getWorldQuaternion(new Quaternion()) const expected = new Quaternion().setFromEuler(new Euler(...rpy, 'ZYX')) - expect(turned?.angleTo(expected)).toBeLessThan(1e-9) + expect(turned?.angleTo(expected)).toBeLessThan(1e-6) }) }) diff --git a/src/usd-hierarchy.ts b/src/usd-hierarchy.ts index 3e74156..6d2680e 100644 --- a/src/usd-hierarchy.ts +++ b/src/usd-hierarchy.ts @@ -37,7 +37,12 @@ export function buildUsdHierarchy( return { root: source, setJointValue: () => undefined } } + // The loader turns the stage's Z up to Y up and applies metersPerUnit on the + // group it returns; the rebuilt tree keeps that frame. const root = new Group() + root.position.copy(source.position) + root.quaternion.copy(source.quaternion) + root.scale.copy(source.scale) const motionGroups = new Map() const children = new Set() @@ -47,7 +52,8 @@ export function buildUsdHierarchy( if (!(parent && child)) continue const frame = new Group() frame.position.fromArray(joint.origin.xyz) - frame.rotation.set(...joint.origin.rpy, 'XYZ') + // URDF roll, pitch, yaw are fixed-axis: R = Rz(yaw) · Ry(pitch) · Rx(roll). + frame.rotation.set(...joint.origin.rpy, 'ZYX') const motion = new Group() if (joint.type !== 'fixed') markArticraftJointTarget(motion, joint.name) frame.add(motion) From 9afb94bf3dc16331a905a09004561a79628a9e5e Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 22:37:56 -0400 Subject: [PATCH 13/14] test: Articraft declares a mechanism capability for the host's Play/Stop, E and walkthrough, failing first Co-Authored-By: Claude Opus 5.5 (1M context) --- src/mechanism.test.ts | 85 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/mechanism.test.ts diff --git a/src/mechanism.test.ts b/src/mechanism.test.ts new file mode 100644 index 0000000..3705df2 --- /dev/null +++ b/src/mechanism.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { useInteractive } from '@pascal-app/core' +import { articraftAssetDefinition } from './definition' +import { articraftMotionPlaying, useArticraftMotion } from './motion-state' +import { ArticraftAssetNode } from './schema' + +const node = ArticraftAssetNode.parse({ + id: 'articraft_mechanism_test', + catalogId: 'lamp', + title: 'Task lamp', + source: 'articraft-10k', + artifact: { format: 'urdf', url: 'https://assets.example/model.urdf' }, + joints: [ + { + name: 'arm', + type: 'revolute', + parent: 'base', + child: 'arm', + origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] }, + limits: { lower: 0, upper: 1 }, + }, + ], + attribution: { creator: 'Articraft authors', license: 'CC-BY-4.0', sourceUrl: 'https://x.test' }, +}) +const still = ArticraftAssetNode.parse({ ...node, id: 'articraft_still', joints: [] }) + +type HostMechanisms = { + mechanisms?: Record + setMechanism?: (nodeId: string, on: boolean) => void +} +const mechanism = ( + articraftAssetDefinition.capabilities as { + mechanism?: { + has: (node: unknown) => boolean + isOn: (node: unknown, state: object) => boolean + set: (node: unknown, on: boolean) => void + verb?: string + } + } +).mechanism + +/** A host whose core keeps single-switch mechanisms in `useInteractive.mechanisms`. */ +function installHostMechanisms() { + const set = useInteractive.setState as (patch: HostMechanisms) => void + set({ + mechanisms: {}, + setMechanism: (nodeId, on) => + set({ + mechanisms: { + ...(useInteractive.getState() as HostMechanisms).mechanisms, + [nodeId]: on, + }, + }), + }) +} + +describe('Articraft mechanism capability', () => { + beforeEach(() => { + useInteractive.setState(useInteractive.getInitialState(), true) + useArticraftMotion.setState({ playing: {} }) + }) + + test('declares the movable joints for Play/Stop, E and the walkthrough', () => { + expect(mechanism?.has(node)).toBe(true) + expect(mechanism?.has(still)).toBe(false) + expect(mechanism?.verb).toBe('run') + }) + + test("runs through the host's transient mechanism state when the host has it", () => { + installHostMechanisms() + mechanism?.set(node, true) + const state = useInteractive.getState() as HostMechanisms + expect(state.mechanisms?.[node.id]).toBe(true) + expect(mechanism?.isOn(node, state)).toBe(true) + expect(articraftMotionPlaying(node.id)).toBe(true) + mechanism?.set(node, false) + expect(articraftMotionPlaying(node.id)).toBe(false) + }) + + test('keeps working on hosts without it (plugin-local playback)', () => { + mechanism?.set(node, true) + expect(articraftMotionPlaying(node.id)).toBe(true) + expect(mechanism?.isOn(node, useInteractive.getState())).toBe(true) + }) +}) From 1b7704a041279b37ce3918c03e2c055470b1ee4c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 26 Sep 2026 22:39:06 -0400 Subject: [PATCH 14/14] feat: Pascal's Play/Stop, E and walkthrough run Articraft motion Declares capabilities.mechanism (movable joints, run/stop, verb 'run'). Where the host core keeps single-switch mechanisms in useInteractive (mechanisms, setMechanism), playback lives there, so the shared action-bar Play/Stop, the E key, the walkthrough and the baked viewer drive the same transient state as the inspector; older hosts ignore the capability and keep playback in the plugin. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++- src/definition.ts | 33 ++++++++++++++++++ src/joint-controls.tsx | 6 ++-- src/motion-state.ts | 41 ++++++++++++++++++++++- src/renderer.tsx | 4 +-- src/test-support/joint-controls-probe.tsx | 3 ++ 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5fce372..d39541b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ hosted service from the Articraft researchers. - Live position, rotation, scale, and joint controls in the Pascal inspector; radians stay radians in scene data and are displayed as degrees only for people. - Transient, selection-independent motion: limited joints sweep their range and - continuous joints turn. + continuous joints turn. On Pascal hosts with `capabilities.mechanism`, the + shared Play/Stop button, the E key, the walkthrough and the baked viewer run it + too, through the host's transient `useInteractive.mechanisms`. - Native looping glTF animation clips for those articulated joints when a Pascal project is exported to GLB; the saved joint pose remains the file's rest pose. - A registry-native 2D footprint that follows each asset's persisted position, diff --git a/src/definition.ts b/src/definition.ts index 80554d4..ed9760d 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -3,6 +3,11 @@ import type { Object3D } from 'three' import { bakeArticraftAnimation } from './animation' import { ARTICRAFT_ICON } from './brand' import { buildArticraftFloorplan } from './floorplan' +import { + articraftMotionPlaying, + hostMechanismsOf, + setArticraftMotionPlaying, +} from './motion-state' import { ArticraftAssetNode } from './schema' type Definition = NodeDefinition & Record @@ -22,6 +27,32 @@ const floorPlacement = { collides: false, } +function movableArticraft(node: unknown): node is ArticraftAssetNode { + const value = node as Partial | null + return ( + value?.type === 'articraft:asset' && + Array.isArray(value.joints) && + value.joints.some((joint) => joint.type !== 'fixed') + ) +} + +/** + * Pascal's `capabilities.mechanism`: the host's Play/Stop, E key, walkthrough + * and baked viewer run an asset's joints through it. Hosts without the + * capability ignore it. + */ +const mechanism = { + has: (node: unknown) => movableArticraft(node), + isOn: (node: { id: string }, state: object) => { + const host = hostMechanismsOf(state) + return typeof host.setMechanism === 'function' + ? host.mechanisms?.[node.id] === true + : articraftMotionPlaying(node.id) + }, + set: (node: { id: string }, on: boolean) => setArticraftMotionPlaying(node.id, on), + verb: 'run' as const, +} + const rotateHandle: HandleDescriptor = { kind: 'arc-resize', axis: 'angular', @@ -81,6 +112,8 @@ export const articraftAssetDefinition: Definition = { }, }), capabilities: { + // Spread, not written inline: hosts before the capability do not know the key. + ...{ mechanism }, movable: { axes: ['x', 'z'], gridSnap: true }, rotatable: { axes: ['y'], diff --git a/src/joint-controls.tsx b/src/joint-controls.tsx index 187a133..54dd761 100644 --- a/src/joint-controls.tsx +++ b/src/joint-controls.tsx @@ -5,7 +5,7 @@ import { PanelSection, SliderControl } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { usePrefersReducedMotion } from './motion' -import { setArticraftMotionPlaying, useArticraftMotion } from './motion-state' +import { setArticraftMotionPlaying, useArticraftMotionPlaying } from './motion-state' import type { ArticraftAssetNode } from './schema' import type { ArticraftJoint } from './types' @@ -19,9 +19,7 @@ export default function ArticraftJointControls() { ? (value as unknown as ArticraftAssetNode) : null }) - const playing = useArticraftMotion((state) => - node ? state.playing[node.id] === true : false, - ) + const playing = useArticraftMotionPlaying(node?.id) const reducedMotion = usePrefersReducedMotion() const movable = useMemo( () => node?.joints.filter((joint) => joint.type !== 'fixed') ?? [], diff --git a/src/motion-state.ts b/src/motion-state.ts index 1077e65..05028db 100644 --- a/src/motion-state.ts +++ b/src/motion-state.ts @@ -1,19 +1,49 @@ +import { useInteractive } from '@pascal-app/core' import { create } from 'zustand' /** * Which placed assets are playing their motion. Playback is transient, like * Pascal's own mechanisms: never saved, undone or shared. The scene keeps only * each asset's rest pose (`jointValues`). + * + * Hosts whose core keeps single-switch mechanisms in `useInteractive` + * (`mechanisms`, `setMechanism`) hold it there, so their Play/Stop, E key, + * walkthrough and baked viewer drive the same state as the inspector. Older + * hosts have no such map, and playback stays in this plugin's store. */ export const useArticraftMotion = create<{ playing: Record }>(() => ({ playing: {}, })) +/** The host's mechanism state, when its core has one. */ +export type HostMechanisms = { + mechanisms?: Readonly> + setMechanism?: (nodeId: string, on: boolean) => void +} + +/** Reads a host interactive state (typed per host core version) as its mechanism fields. */ +export function hostMechanismsOf(state: object): HostMechanisms { + return state as HostMechanisms +} + +function hostMechanisms(): HostMechanisms | null { + const state = hostMechanismsOf(useInteractive.getState()) + return typeof state.setMechanism === 'function' ? state : null +} + export function articraftMotionPlaying(nodeId: string): boolean { - return useArticraftMotion.getState().playing[nodeId] === true + const host = hostMechanisms() + return host + ? host.mechanisms?.[nodeId] === true + : useArticraftMotion.getState().playing[nodeId] === true } export function setArticraftMotionPlaying(nodeId: string, on: boolean): void { + const host = hostMechanisms() + if (host) { + if ((host.mechanisms?.[nodeId] === true) !== on) host.setMechanism?.(nodeId, on) + return + } useArticraftMotion.setState((state) => { if ((state.playing[nodeId] === true) === on) return state const playing = { ...state.playing } @@ -22,3 +52,12 @@ export function setArticraftMotionPlaying(nodeId: string, on: boolean): void { return { playing } }) } + +/** Whether `nodeId` is playing, re-rendering when that changes. */ +export function useArticraftMotionPlaying(nodeId: string | undefined): boolean { + const hosted = useInteractive((state) => + nodeId ? hostMechanismsOf(state).mechanisms?.[nodeId] === true : false, + ) + const local = useArticraftMotion((state) => (nodeId ? state.playing[nodeId] === true : false)) + return hostMechanisms() ? hosted : local +} diff --git a/src/renderer.tsx b/src/renderer.tsx index 5c873b0..12b1c45 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -22,7 +22,7 @@ import URDFLoader from 'urdf-loader' import { markArticraftJointTarget } from './animation' import { createArticraftAppearanceController } from './appearance' import { motionValueAtTime, usePrefersReducedMotion } from './motion' -import { setArticraftMotionPlaying, useArticraftMotion } from './motion-state' +import { setArticraftMotionPlaying, useArticraftMotionPlaying } from './motion-state' import type { ArticraftAssetNode } from './schema' import { resolveArticraftRootTransform } from './transform' import { buildUsdHierarchy, type LoadedArticulationSource } from './usd-hierarchy' @@ -70,7 +70,7 @@ export function ArticraftVisual({ const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) const sceneTheme = useViewer((state) => state.sceneTheme) - const playing = useArticraftMotion((state) => !ghost && state.playing[node.id] === true) + const playing = useArticraftMotionPlaying(ghost ? undefined : node.id) const shouldAnimate = playing && !reducedMotion && diff --git a/src/test-support/joint-controls-probe.tsx b/src/test-support/joint-controls-probe.tsx index eaf8bf7..9de6d11 100644 --- a/src/test-support/joint-controls-probe.tsx +++ b/src/test-support/joint-controls-probe.tsx @@ -38,6 +38,9 @@ const state = { nodes: { [NODE_ID]: node }, updateNode: () => undefined } stubHostModules({ '@pascal-app/core': { + useInteractive: Object.assign((select: (value: object) => unknown) => select({}), { + getState: () => ({}), + }), useScene: Object.assign((select: (value: typeof state) => unknown) => select(state), { getState: () => state, }),