Custom TypeScript Speckle Connector

Hi Speckle guys,

I’m looking to create a web app, where I can upload a XML file and it will be saved to Speckle.
I wanted to do it all client side, so I don’t need any backend, since it is a rather simple app.

I can parse the XML into JS objects and serialize it and send it to Speckle. All works :ok_hand:

However, I can’t see my geometry in Speckle and I can’t drill down into my collection.

So I’m guessing, I’m missing some fields in my objects.

Here is my code:

The ServerTransport is a copy-paste from ServerTransport.ts
The BaseObjectSerializer is copy-paste from BaseObjectSerializer.ts

export const processFile = async ({ file, token, streamId, branchName, message }: ProcessFileProps) => {
  console.log('Processing file...', file.name)
  const geometry = await parseFileObjects({ content: await file.text() })
  const serverTransport = new ServerTransport(import.meta.env.VITE_SPECKLE_SERVER_URL, token, streamId)
  const serializer = new BaseObjectSerializer([serverTransport])
  const serialized = await serializer.SerializeBase(geometry)

  await serverTransport.CreateCommit(branchName, serialized.id, message)
}

The parseFileObjects looks like this:

export const parseFileObjects = async ({ content }: { content: string }) => {
  const x2js = new X2JS()
  // @ts-expect-error - x2js types are incorrect
  const nodes = x2js.xml2js(content).KnudeGroup.Knude
  return {
    speckle_type: 'Speckle.Core.Models.Collection',
    name: 'Dandas Nodes',
    collectionType: 'Dandas Nodes',
    '@elements': nodes.map(node => (
      {
        ...node,
        speckle_type: 'Objects.Geometry.Extrusion',
        capped: true,
        profile: {
          speckle_type: 'Objects.Geometry.Circle', radius: node.DiameterBredde / 2,
        },
        pathEnd: {
          speckle_type: 'Objects.Geometry.Point',
          x: node.XKoordinat,
          y: node.YKoordinat,
          z: node.Terraenkote,
        },
        pathStart: { speckle_type: 'Objects.Geometry.Point', x: node.XKoordinat, y: node.YKoordinat, z: node.Bundkote },
      }
    )),
  }
}

All my geometry are cylinders, so I’m trying to create an extrusion, with a circle as base.

Would really appreciate if someone could help me out :slight_smile:

1 Like

Finally welcome @chrkong !!

Our web viewer requires a display value property to be present on displayable objects - this is typically mesh, but could it be the 2D primitives for the extrusions you are defining?

In terms of generating that geometry, I’m less familiar with JS ecosystem. However I have been sponsoring the opensource project https://docs.bitbybit.dev/

1 Like

Yes, I thought it had something to do with the displayValue, I thought maybe defining the objects as Objects.Geometry would infer the displayValue.

How should I populate the displayValue object?

It is an array of dsiplayable geometries

You mean like this?

export const parseFileObjects = async ({ content }: { content: string }) => {
  const x2js = new X2JS()
  // @ts-expect-error - x2js types are incorrect
  const nodes = x2js.xml2js(content).KnudeGroup.Knude
  return {
    speckle_type: 'Speckle.Core.Models.Collection',
    name: 'Dandas Nodes',
    collectionType: 'Dandas Nodes',
    '@elements': nodes.map(node => (
      {
        ...node,
        '@displayValue': {
          speckle_type: 'Objects.Geometry.Extrusion',
          capped: true,
          profile: {
            speckle_type: 'Objects.Geometry.Circle', radius: node.DiameterBredde / 2,
          },
          pathEnd: {
            speckle_type: 'Objects.Geometry.Point',
            x: node.XKoordinat,
            y: node.YKoordinat,
            z: node.Terraenkote,
          },
          pathStart: {
            speckle_type: 'Objects.Geometry.Point',
            x: node.XKoordinat,
            y: node.YKoordinat,
            z: node.Bundkote,
          },
        },
      }
    )),
  }
}

But it still gives the same outcome :thinking:

Ahh no,

The displayValue prop will accept Points Lines Polylines and Meshes as primitives. Essentially, the extrusion geomtries are not supported by the viewer

So in abstract:

Base:
    name: "Chrkong Extrusion".
    speckle_type: "Objects.Geometry.Extrusion",
    ...
    extrusion props
    ...
    displayValue: [ Mesh ]

At which point, unless you are making use of the Extrusion information downstream, this may be unnecessary inclusion and you could just define a custom Base object with a displayValue and any data props you want

I have no love for the extrusion, I just thought it was an easy way to generate a cylinder, but if it is not supported, then there is no need for it.

Instead I tried to generate a mesh with a cylinder shape:

export const parseFileObjects = async ({ content }: { content: string }) => {
  const x2js = new X2JS()
  // @ts-expect-error - x2js types are incorrect
  const nodes = x2js.xml2js(content).KnudeGroup.Knude
  return {
    speckle_type: 'Speckle.Core.Models.Collection',
    name: 'Dandas Nodes',
    collectionType: 'Dandas Nodes',
    '@elements': nodes.map(node => (
      {
        ...node,
        speckle_type: 'Objects.BuiltElements.DandasNode',
        '@displayValue': [generateGeometry(node)],
      }
    )),
  }
}


const generateGeometry = (node: unknown): Mesh => {
  const radius = node.DiameterBredde / 2
  const height = node.Terraenkote - node.Bundkote
  const cylinder = new CylinderGeometry(radius, radius, height, 24)
  const vertices = Array.from(cylinder.attributes.position.array)
  return {
    vertices: vertices,
    faces: vertices.map((_, index) => index),
    units: 'M',
    speckle_type: 'Objects.Geometry.Mesh',
  }
}

The CylinderGeometry is from Three.js, btw

Still no luck though…

Hi @chrkong

I’ve noticed an issue with the way you are defining the faces. The speckle’s Mesh face indices start with the cardinality of the face. So if the face is a triangle it’s going to be a 3, if it’s a quad a 4 and n for n-gons.

Because you are using three.js’s CylinderGeometry, here’s how you can build the correct face list

const cylinder = new CylinderGeometry(5, 5, 10, 24)
const cylinderIndices = cylinder.index?.array as Array<number>
const cylinderPositions = cylinder.attributes.position.array as Array<number>
const cylinderFaces = []
for (let k = 0; k < cylinderIndices.length; k += 3) {
  cylinderFaces.push(3)
  cylinderFaces.push(
    cylinderIndices[k],
    cylinderIndices[k + 1],
    cylinderIndices[k + 2]
  )
}

return {
    vertices: cylinderPositions,
    faces: cylinderFaces,
    units: 'M',
    speckle_type: 'Objects.Geometry.Mesh',
  }

I can’t be sure this is the only issue that’s stopping you from properly building the stream, but in case there is still nothing to be seen in the speckle viewer, please provide us with the stream link so we can look further into it

Cheers

I’ve implemented your suggestions, but I still can’t see any geometry.

Here is the link for the model:
https://app.speckle.systems/projects/64771624e4/models/66edba618a

Hi @chrkong

I’ve had a look and there are two issues:

  • All your objects are missing ids. The viewer will ignore any object that doesn’t have an id. As a quick fix you could use MathUtils.generateUUID() from three.js (since you are already importing it). Both the DandasNode and their display values will need ids
  • Some of the display values have null vertex positions:

This is likely to happen because some DandasNodes have undefined DiameterBredde or Terraenkote or Bundkote which inevitably lead to bad cylinder geometries.

Cheers

1 Like

Okay, I’ve added ids to the objects, but now the viewer wont load anything and I get this in the console:
image

The model can be found here: Viewer - dandas.xml - Speckle

Hi @chrkong

You are sending meshes with null values for vertex positions

chafdf

Like I said in the last post, that’s happening because when you are generating the geometries DandasNodes have undefined DiameterBredde or Terraenkote or Bundkote which inevitably lead to bad cylinder geometries.

Additionally, you display values still have no id

Cheers

1 Like

Looks like it is working now :grinning:

Thanks for the help @alex and @jonathon :star_struck:

3 Likes

I have a couple of followup questions:

  1. My geometry is quite far from the origin and in general spread quite thin, which causes the usual problems with rendering and zooming. Is there a way to solve this? Like a local origin or something?

  2. I have added some fields with the @ prefix with the expectation that they would become detached and that I would have to de-reference them, when I pull the data again from the service, which is not the case. Do I have to implement the detachment logic in my ObjectSerializer and is there some guide how to do that?

Link to project

Indeed, the @ decorator is used as a signififier to the serializer/deserializer to detach (and chunk) data. Because you are in the brave uncharted territory of a non-existant TS SDK the examples I have thrown your way from Excel connector may have omitted this feature of its serializer.

I can only point you to the implementations in:

specklepy

and speckle-sharp

Any chance that the TS boss @vwb has such an implementation lying around?

Otherwise, I’ll give it a try on my own :slight_smile:

Ping @oguzhankoral for the sketchup implementation!
(sketchup has/had this!)

Actually, I think I got the detach part sorted.

Add this to the PreserializeObject:

    if (detachable) {
      const serialized = await this.SerializeBaseWithClosures(object, [...closures])
      return new ObjectReference(serialized.id)
    }

And updated PreserializeEachObjectProperty with

    for (const key of objectKeys) {
      const detachable = key.startsWith('@')
      converted.set(
        BaseObjectSerializer.CleanKey(key),
        await this.PreserializeObject(o[key as keyof object], closures, detachable),
      )
    }

Full code below

import { enc, MD5 } from 'crypto-js'
import { IBase, ITransport } from './interfaces.ts'

/**
 * Serializer for Speckle objects written in Typescript
 */
export class BaseObjectSerializer {
  constructor(public transports: ITransport[]) {}

  public async SerializeBase(object: object): Promise<SerializedBase> {
    return await this.SerializeBaseWithClosures(object, [])
  }

  private async SerializeBaseWithClosures(object: object, closures: Array<Map<string, number>>) {
    const thisClosure = new Map<string, number>()
    closures.push(thisClosure)

    const converted = await this.PreserializeEachObjectProperty(object, closures)
    let json = this.SerializeMap(converted)
    const id = this.GetId(json)
    converted.set('id', id)

    this.AddSelfToParentClosures(id, closures)
    if (thisClosure.size > 0) {
      converted.set('__closure', Object.fromEntries(thisClosure))
    }
    converted.set('totalChildrenCount', thisClosure.size)

    json = this.SerializeMap(converted)
    await this.StoreObject(converted)
    return new SerializedBase(id, json)
  }

  private async PreserializeObject(
    object: unknown,
    closures: Array<Map<string, number>>,
    detachable: boolean = false,
  ): Promise<unknown> {
    if (!(object instanceof Object) || object instanceof String) {
      return object
    }

    if (detachable) {
      const serialized = await this.SerializeBaseWithClosures(object, [...closures])
      return new ObjectReference(serialized.id)
    }

    if (object instanceof DataChunk) {
      const serialized = await this.SerializeBaseWithClosures(object, [...closures])
      return new ObjectReference(serialized.id)
    }

    if (object instanceof Array) {
      // chunk array into 5000 by default
      const chunkSize = 5000
      if (object.length > chunkSize) {
        let serializedCount = 0
        const data = new Array<DataChunk>()
        while (serializedCount < object.length) {
          const dataChunkCount = Math.min(chunkSize, object.length - serializedCount)
          data.push(new DataChunk(object.slice(serializedCount, serializedCount + dataChunkCount)))
          serializedCount += dataChunkCount
        }
        return await this.PreserializeObject(data, closures)
      }

      const convertedList = new Array<unknown>()
      for (const element of object) {
        convertedList.push(await this.PreserializeObject(element, closures))
      }
      return convertedList
    }

    if (object instanceof Object) {
      return Object.fromEntries(await this.PreserializeEachObjectProperty(object, closures))
    }

    throw new Error(`Cannot serialize object ${object}`)
  }

  private async PreserializeEachObjectProperty(
    o: object,
    closures: Array<Map<string, number>>,
  ): Promise<Map<string, unknown>> {
    const converted = new Map<string, unknown>()

    const getters = Object.entries(Object.getOwnPropertyDescriptors(Reflect.getPrototypeOf(o)))
      .filter(([key, descriptor]) => typeof descriptor.get === 'function' && key !== '__proto__')
      .map(([key]) => key)

    const objectKeys = new Array<string>()
    objectKeys.push(...Object.keys(o))
    objectKeys.push(...getters)

    for (const key of objectKeys) {
      const detachable = key.startsWith('@')
      converted.set(
        BaseObjectSerializer.CleanKey(key),
        await this.PreserializeObject(o[key as keyof object], closures, detachable),
      )
    }

    return converted
  }

  private static disallowedCharacters: string[] = ['.', '/']

  private static CleanKey(originalKey: string): string {
    const newStringChars = []
    for (let i = 0; i < originalKey.length; i++) {
      if (i == 1 && originalKey[i] == '@' && originalKey[0] == '@') {
        continue
      }
      if (this.disallowedCharacters.includes(originalKey[i])) {
        continue
      }

      newStringChars.push(originalKey[i])
    }
    return newStringChars.join('')
  }

  private async StoreObject(object: Map<string, unknown>) {
    for (const transport of this.transports) {
      await transport.SaveObject(object)
    }
  }

  private SerializeMap(map: Map<string, unknown>): string {
    return JSON.stringify(Object.fromEntries(map))
  }

  private GetId(json: string): string {
    return MD5(json).toString(enc.Hex)
  }

  private AddSelfToParentClosures(objectId: string, closureTables: Array<Map<string, number>>) {
    // only go to closureTable length - 1 because the last closure table belongs to the object with the
    // provided id
    const parentClosureTablesCount = closureTables.length - 1

    for (let parentLevel = 0; parentLevel < parentClosureTablesCount; parentLevel++) {
      const childDepth = parentClosureTablesCount - parentLevel
      closureTables[parentLevel].set(objectId, childDepth)
    }
  }
}

export class DataChunk implements IBase {
  public speckle_type = 'Speckle.Core.Models.DataChunk'
  public data: unknown[]

  constructor(data: unknown[] | null) {
    this.data = data ?? []
  }
}

export class ObjectReference implements IBase {
  public speckle_type = 'reference'

  constructor(public referencedId: string) {}
}

export class SerializedBase {
  constructor(
    public id: string,
    public json: string,
  ) {}
}

3 Likes

However, I still feel like something is wrong with my objects and I am tracing it to the collection.

Here is my viewer:

So I have a collection, which looks like it has nothing in it / only includes a single element.

But if I got Dev Mode:

I can see that I got stuff.

Compared to a standard Revit model:

So I would expect that my Dandas Nodes looks like the Site from Revit.
However, Dandas Nodes are called Collection and is a object, while the Site is an Array Collection and is a array.

The code I use to construct my Dandas Nodes object looks like this:

{
    speckle_type: 'Speckle.Core.Models.Collection',
    name: 'Dandas Nodes',
    id: uuidv4(),
    collectionType: 'Dandas Nodes',
    '@elements': nodes
      .filter((node) => ['1', '4', '7'].indexOf(node.KnudeKode) >= 0 && node.FormKode === '1')
      .map((node) => ({
        ...node,
        name: node._Knudenavn,
        id: uuidv5(node._Knudenavn, uuidv5.URL),
        parameters: generateParameters(node),
        speckle_type: 'Objects.BuiltElements.DandasNode',
        displayValue: [generateGeometry(node)],
      })),
  }

What am I missing?

I can’t see how the Dandas Nodes and Site differs in Dev Mode

It was in Sketchup, we do it in Ruby anymore but the Excel connector already has it for a while. speckle-excel/src/plugins/BaseObjectSerializer.ts at 3dad1a0849f7adb5996c97cf63f7c46f366431c1 · specklesystems/speckle-excel · GitHub

1 Like