Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
94 changes: 80 additions & 14 deletions bun.lock

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions src/animation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)).toBeLessThan(1e-3)
})

test('does not emit an animation without a resolved movable joint target', () => {
const node = ArticraftAssetNode.parse({
id: 'articraft_static_test',
Expand Down
7 changes: 3 additions & 4 deletions src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -36,7 +35,7 @@ export function bakeArticraftAnimation(
const tracks = [] as Array<QuaternionKeyframeTrack | VectorKeyframeTrack>
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

Expand Down Expand Up @@ -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
}
Expand Down
42 changes: 42 additions & 0 deletions src/catalog-node.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
23 changes: 23 additions & 0 deletions src/catalog-node.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
3 changes: 1 addition & 2 deletions src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export const articraftAssetDefinition: Definition = {
parts: [],
joints: [],
jointValues: {},
motionEnabled: false,
attribution: {
creator: 'Articraft authors',
license: 'CC-BY-4.0',
Expand Down Expand Up @@ -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.',
},
}
22 changes: 22 additions & 0 deletions src/joint-controls.test.ts
Original file line number Diff line number Diff line change
@@ -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<Array<{ label: string; restoreOnCommit: unknown }>>(
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([])
})
})
33 changes: 13 additions & 20 deletions src/joint-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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') ?? [],
Expand All @@ -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,
)
}

Expand All @@ -73,7 +70,6 @@ export default function ArticraftJointControls() {
min={0.05}
onChange={setScale}
precision={2}
restoreOnCommit={false}
step={0.05}
value={node.scale}
/>
Expand All @@ -85,7 +81,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]}
Expand All @@ -99,7 +94,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])}
Expand All @@ -126,7 +120,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
Expand All @@ -139,15 +132,15 @@ export default function ArticraftJointControls() {
{movable.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button
aria-pressed={node.motionEnabled && !reducedMotion}
aria-pressed={playing && !reducedMotion}
disabled={reducedMotion}
onClick={() => setMotionEnabled(!node.motionEnabled)}
style={buttonStyle(node.motionEnabled && !reducedMotion, reducedMotion)}
onClick={() => setArticraftMotionPlaying(node.id, !playing)}
style={buttonStyle(playing && !reducedMotion, reducedMotion)}
type="button"
>
{reducedMotion
? 'Motion reduced'
: node.motionEnabled
: playing
? 'Stop motion'
: 'Start motion'}
</button>
Expand Down
15 changes: 15 additions & 0 deletions src/motion-state.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
Loading
Loading