From 53917156954c7da321aa3a979c68d10b9ce613c3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 09:41:31 -0400 Subject: [PATCH] fix: keep Articraft catalog bounds through placement --- HOST_INTEGRATION.md | 9 +- src/bounds.test.ts | 73 ++++++++++++++ src/bounds.ts | 227 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/renderer.tsx | 8 +- src/schema.test.ts | 8 +- src/schema.ts | 2 + src/tool.tsx | 2 + src/types.ts | 6 ++ 9 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 src/bounds.test.ts 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.test.ts b/src/bounds.test.ts new file mode 100644 index 0000000..c13d837 --- /dev/null +++ b/src/bounds.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { bottomCentreFrame, objExtent, urdfRestBounds } from './index' + +// 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/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/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 c0c7dd0..fcab1ac 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -180,7 +180,13 @@ export function ArticraftVisual({ useEffect(() => () => fallbackMaterial.dispose(), [fallbackMaterial]) - if (loaded) return + if (loaded) { + return ( + + + + ) + } return ( { test('persists an immutable artifact and joint pose', () => { @@ -38,6 +38,11 @@ describe('Articraft asset schema', () => { expect(node.jointValues.globe_spin).toBe(1.25) expect(node.motionEnabled).toBe(true) expect(node.artifact.sha256).toBe('a'.repeat(64)) + const placed = ArticraftAssetNode.parse({ ...node, offset: [0.01, 0.12, -0.02] }) + const reopened = ArticraftAssetNode.parse(JSON.parse(JSON.stringify(placed))) + expect(reopened).toEqual(placed) + expect(reopened.offset).toEqual([0.01, 0.12, -0.02]) + expect(reopened.motionEnabled).toBe(true) }) test('keeps older nodes still until motion is explicitly enabled', () => { @@ -54,5 +59,6 @@ describe('Articraft asset schema', () => { }) expect(node.motionEnabled).toBe(false) + expect(node.offset).toBeUndefined() }) }) diff --git a/src/schema.ts b/src/schema.ts index 970f4f1..88ddfa2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -38,6 +38,8 @@ export const ArticraftAssetNode = BaseNode.extend({ }), thumbnailUrl: z.string().url().optional(), dimensions: Vec3.default([1, 1, 1]), + /** Moves the loaded asset so its rest pose stands on its bottom centre at the node origin. */ + offset: Vec3.optional(), parts: z .array( z.object({ diff --git a/src/tool.tsx b/src/tool.tsx index 4ab685b..e45781c 100644 --- a/src/tool.tsx +++ b/src/tool.tsx @@ -26,6 +26,7 @@ export default function ArticraftTool() { artifact: item.artifact, thumbnailUrl: item.thumbnailUrl, dimensions: item.dimensions, + offset: item.offset, parts: item.parts, joints: item.joints, jointValues: item.defaultJointValues, @@ -49,6 +50,7 @@ export default function ArticraftTool() { 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/types.ts b/src/types.ts index 40829cc..410fa6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,7 +57,13 @@ export type ArticraftCatalogItem = { source: ArticraftSource artifact: ArticraftArtifact thumbnailUrl?: string + /** Rest-pose size in Pascal's frame (Y up, metres). */ dimensions: Vec3 + /** + * Moves the loaded asset so its rest pose stands on its bottom centre at the + * node origin (`bottomCentreFrame`). Absent in records synced before it existed. + */ + offset?: Vec3 parts: ArticraftPart[] joints: ArticraftJoint[] defaultJointValues: Record