-
Notifications
You must be signed in to change notification settings - Fork 2
geometry decode fixes #912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@viamrobotics/motion-tools': minor | ||
| --- | ||
|
|
||
| Render STL collision meshes alongside PLY, and rotate unoriented link geometry into its own frame |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { meshContentType, parseMeshInput } from '$lib/mesh' | ||
|
|
||
| const asciiStl = `solid tri | ||
| facet normal 0 0 1 | ||
| outer loop | ||
| vertex 0 0 0 | ||
| vertex 1 0 0 | ||
| vertex 0 1 0 | ||
| endloop | ||
| endfacet | ||
| endsolid tri | ||
| ` | ||
|
|
||
| const asciiPly = `ply | ||
| format ascii 1.0 | ||
| element vertex 3 | ||
| property float x | ||
| property float y | ||
| property float z | ||
| element face 1 | ||
| property list uchar int vertex_index | ||
| end_header | ||
| 0 0 0 | ||
| 1 0 0 | ||
| 0 1 0 | ||
| 3 0 1 2 | ||
| ` | ||
|
|
||
| const bytes = (text: string) => new TextEncoder().encode(text) | ||
|
|
||
| /** | ||
| * Binary on purpose: `STLLoader` classifies ASCII by regex, so an ASCII fixture parses out of an | ||
| * oversized buffer and the subarray and short-input cases below pass with their guards deleted. | ||
| */ | ||
| const binaryStl = (triangles = 1): Uint8Array => { | ||
| const buffer = new ArrayBuffer(84 + 50 * triangles) | ||
| const view = new DataView(buffer) | ||
| view.setUint32(80, triangles, true) | ||
|
|
||
| let offset = 84 | ||
| for (let i = 0; i < triangles; i += 1) { | ||
| // normal (0,0,1) then the three corners of a unit triangle | ||
| for (const value of [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0]) { | ||
| view.setFloat32(offset, value, true) | ||
| offset += 4 | ||
| } | ||
| offset += 2 | ||
| } | ||
|
|
||
| return new Uint8Array(buffer) | ||
| } | ||
|
|
||
| // Mapped rather than spread: a spread passes one argument per byte and blows the call stack on a | ||
| // fixture of any size. | ||
| const base64 = (data: Uint8Array) => | ||
| btoa(Array.from(data, (byte) => String.fromCharCode(byte)).join('')) | ||
|
|
||
| describe('meshContentType', () => { | ||
| it.each([ | ||
| ['ply', 'ply'], | ||
| ['stl', 'stl'], | ||
| ['STL', 'stl'], | ||
| [' Ply ', 'ply'], | ||
| ['model/stl', 'stl'], | ||
| ['application/ply; charset=binary', 'ply'], | ||
| ])('reads %s as %s', (raw, expected) => { | ||
| expect(meshContentType(raw)).toBe(expected) | ||
| }) | ||
|
|
||
| it.each([['obj'], ['dae'], [''], [undefined]])('does not claim to handle %s', (raw) => { | ||
| expect(meshContentType(raw)).toBeUndefined() | ||
| }) | ||
|
|
||
| it.each([['meshes/ur20/collision/base.stl'], ['package://arm/link_1.stl'], ['/etc/thing.ply']])( | ||
| 'does not read a file path like %s as a content type', | ||
| (raw) => { | ||
| expect(meshContentType(raw)).toBeUndefined() | ||
| } | ||
| ) | ||
| }) | ||
|
|
||
| describe('parseMeshInput', () => { | ||
| it('parses an stl mesh into real vertices', () => { | ||
| const geometry = parseMeshInput(bytes(asciiStl), 'stl') | ||
| expect(geometry.getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it('parses a ply mesh into real vertices', () => { | ||
| const geometry = parseMeshInput(bytes(asciiPly), 'ply') | ||
| expect(geometry.getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it.each([[undefined], [''], ['obj']])('falls back to ply for a content type of %s', (raw) => { | ||
| const geometry = parseMeshInput(bytes(asciiPly), raw) | ||
| expect(geometry.getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it.each([['ply'], ['stl']])('returns an empty geometry for empty %s bytes', (contentType) => { | ||
| expect(parseMeshInput(new Uint8Array(), contentType).getAttribute('position')).toBeUndefined() | ||
| }) | ||
|
|
||
| it('parses a binary stl mesh into real vertices', () => { | ||
| expect(parseMeshInput(binaryStl(2), 'stl').getAttribute('position').count).toBe(6) | ||
| }) | ||
|
|
||
| it.each([ | ||
| ['ascii', () => btoa(asciiStl)], | ||
| ['binary', () => base64(binaryStl())], | ||
| ])('accepts a base64 %s stl as well as bytes', (_label, encode) => { | ||
| expect(parseMeshInput(encode(), 'stl').getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it('parses a binary stl mesh held in a subarray', () => { | ||
| const stl = binaryStl() | ||
| const padded = new Uint8Array(stl.length + 8) | ||
| padded.set(stl, 4) | ||
| const view = padded.subarray(4, 4 + stl.length) | ||
|
|
||
| expect(parseMeshInput(view, 'stl').getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it.each([[1], [19], [83]])( | ||
| 'returns an empty geometry for a %i byte stl rather than throwing', | ||
| (length) => { | ||
| expect(parseMeshInput(new Uint8Array(length), 'stl').getAttribute('position')).toBeUndefined() | ||
| } | ||
| ) | ||
|
|
||
| // Above the 84-byte guard, so the count at offset 80 is read and believed. | ||
| it.each([ | ||
| ['claims 100 triangles and carries none', 84, 100], | ||
| ['claims 2 triangles and carries 1', 84 + 50, 2], | ||
| ])( | ||
| 'returns an empty geometry for a binary stl that %s rather than throwing', | ||
| (_label, length, claimed) => { | ||
| const truncated = new Uint8Array(length) | ||
| new DataView(truncated.buffer).setUint32(80, claimed, true) | ||
|
|
||
| expect(parseMeshInput(truncated, 'stl').getAttribute('position')).toBeUndefined() | ||
| } | ||
| ) | ||
|
|
||
| it.each([ | ||
| ['an empty string', ''], | ||
| ['a truncated base64 payload', btoa('solid t\nendsolid t\n')], | ||
| ['malformed base64', '!!!not base64!!!'], | ||
| ])('returns an empty geometry for %s rather than throwing', (_label, encoded) => { | ||
| expect(parseMeshInput(encoded, 'stl').getAttribute('position')).toBeUndefined() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { commonApi, Geometry as ViamGeometry } from '@viamrobotics/sdk' | ||
| import { createWorld, type World } from 'koota' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
|
|
||
| import { traits } from '$lib/ecs' | ||
|
|
||
| const asciiStl = `solid tri | ||
| facet normal 0 0 1 | ||
| outer loop | ||
| vertex 0 0 0 | ||
| vertex 1 0 0 | ||
| vertex 0 1 0 | ||
| endloop | ||
| endfacet | ||
| endsolid tri | ||
| ` | ||
|
|
||
| const stlGeometry = (): ViamGeometry => | ||
| new ViamGeometry({ | ||
| geometryType: { | ||
| case: 'mesh', | ||
| value: new commonApi.Mesh({ | ||
| contentType: 'stl', | ||
| mesh: new TextEncoder().encode(asciiStl), | ||
| }), | ||
| }, | ||
| }) | ||
|
|
||
| /** | ||
| * Not covered by `mesh.spec.ts`: a regression to `parsePlyInput` here still puts an entity in the | ||
| * world, since `PLYLoader` answers STL bytes with an empty geometry rather than throwing. | ||
| */ | ||
| describe('mesh geometry reaches the trait layer', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good motivation for a separate spec file. |
||
| let world: World | ||
| afterEach(() => world?.destroy()) | ||
|
|
||
| it('Geometry parses an stl mesh into real vertices', () => { | ||
| world = createWorld() | ||
| const entity = world.spawn(traits.Geometry(stlGeometry())) | ||
|
|
||
| expect(entity.get(traits.BufferGeometry)!.getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it('updateGeometryTrait parses an stl mesh for an entity with no prior BufferGeometry', () => { | ||
| world = createWorld() | ||
| const entity = world.spawn(traits.Box()) | ||
|
|
||
| traits.updateGeometryTrait(entity, stlGeometry()) | ||
|
|
||
| expect(entity.has(traits.Box)).toBe(false) | ||
| expect(entity.get(traits.BufferGeometry)!.getAttribute('position').count).toBe(3) | ||
| }) | ||
|
|
||
| it('updateGeometryTrait re-parses an stl mesh for an entity that already holds a BufferGeometry', () => { | ||
| world = createWorld() | ||
| const entity = world.spawn(traits.Geometry(stlGeometry())) | ||
|
|
||
| traits.updateGeometryTrait(entity, stlGeometry()) | ||
|
|
||
| expect(entity.get(traits.BufferGeometry)!.getAttribute('position').count).toBe(3) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { BufferGeometry } from 'three' | ||
|
|
||
| import { parsePlyInput } from '$lib/ply' | ||
| import { parseStlInput } from '$lib/stl' | ||
|
|
||
| export type MeshContentType = 'ply' | 'stl' | ||
|
|
||
| /** | ||
| * Reads a content type, not a path: `meshes/base.stl` belongs in `mesh_file_path`. RDK writes a bare | ||
| * `ply` or `stl`, so the folding and trimming are defense on a proto this repo does not own. | ||
| */ | ||
| export const meshContentType = (raw: string | undefined): MeshContentType | undefined => { | ||
| const value = (raw ?? '').toLowerCase().split(';')[0]?.trim().split('/').at(-1) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The chain handles |
||
| return value === 'ply' || value === 'stl' ? value : undefined | ||
| } | ||
|
|
||
| /** | ||
| * PLY is the fallback rather than an error: it is the assumption every render path already makes. | ||
| * The plan parser does not rely on it, gating on `meshContentType` and skipping what it cannot read. | ||
| */ | ||
| export const parseMeshInput = (mesh: string | Uint8Array, contentType?: string): BufferGeometry => | ||
| meshContentType(contentType) === 'stl' ? parseStlInput(mesh) : parsePlyInput(mesh) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Smart fixture choice. ASCII STL is classified by a regex in
STLLoader, so ASCII bytes would pass the subarray guard even when the fix is absent — the test would be vacuously green. Binary forces theisBinarypath, where the wrong offset is silent (returns empty geometry) rather than throwing.