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
9 changes: 8 additions & 1 deletion HOST_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 11 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,10 @@ 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. 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,
Expand Down Expand Up @@ -71,7 +73,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
34 changes: 26 additions & 8 deletions server/articraft_worker/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions server/articraft_worker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
29 changes: 29 additions & 0 deletions server/tests/test_generation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from articraft_worker.generation import _joints, _parts, _public_manifest, _title
from articraft_worker.models import Artifact, CatalogItem

Expand Down Expand Up @@ -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))
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
73 changes: 73 additions & 0 deletions src/bounds.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0"?>
<!-- comment <link name="ignored"/> -->
<robot name="box">
<material name="red"><color rgba="1 0 0 1"/></material>
<link name="base">
<visual name="body">
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<geometry><box size="1 0.5 0.2"/></geometry>
<material name="red"/>
</visual>
<collision><geometry><box size="9 9 9"/></geometry></collision>
</link>
<link name="lid">
<visual>
<origin xyz="0 0 0.2"/>
<geometry><cylinder radius="0.1" length="0.4"/></geometry>
</visual>
<visual>
<origin xyz="0.3 0 0" rpy="0 0 0"/>
<geometry><mesh filename="assets/handle.obj" scale="0.001 0.001 0.001"/></geometry>
</visual>
</link>
<joint name="hinge" type="revolute">
<parent link="base"/>
<child link="lid"/>
<origin xyz="0.2 0 0.2" rpy="0 0 1.5707963267948966"/>
<axis xyz="0 1 0"/>
<limit lower="0" upper="1.5" effort="1" velocity="1"/>
</joint>
</robot>`

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('<robot name="empty"><link name="a"/></robot>', 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])
})
})
Loading
Loading