Adding a shape
Octahedron is type 10. Everything below is its real code, in the order you would add it. A new shape is the same seven edits with its own numbers.
1. The distance function — primitives/octahedron.wgsl
Section titled “1. The distance function — primitives/octahedron.wgsl”// Octahedron — 8-faced diamond. Vertices at ±size on each axis.fn sdOctahedron(p: vec3f, size: f32) -> f32 { let pa = vec3f(abs(p.x), abs(p.y), abs(p.z)); let m = pa.x + pa.y + pa.z - size; var q: vec3f; if (3.0 * pa.x < m) { q = pa.xyz; } else if (3.0 * pa.y < m) { q = pa.yzx; } else if (3.0 * pa.z < m) { q = pa.zxy; } else { return m * 0.57735027; } let k = clamp(0.5 * (q.z - q.y + size), 0.0, size); return length(vec3f(q.x, q.y - size + k, q.z - k));}One function: the point in the shape’s own frame, the shape’s parameters, the
signed distance back. p arrives already local — the caller has subtracted the
position and undone the rotation.
Add it to the include list in sdf.wgsl, inside a region named for its type
ID:
//#region SDF_TYPE [10]#include "./primitives/octahedron.wgsl"//#endregion SDF_TYPE [10]Prefer an exact distance. A lower bound works — ellipsoid and triangular prism are bounds — but it needs more march steps near grazing angles, and every scene that uses the shape pays for that.
2. The dispatch case — evalObjectSDF in sdf.wgsl
Section titled “2. The dispatch case — evalObjectSDF in sdf.wgsl” //#region SDF_TYPE [10] } else if (typeId < 10.5) { // Type 10: Octahedron — size = (size, _, _) d = sdOctahedron(pTr, o.size_type.x); //#endregion SDF_TYPE [10]The region opens before the else if and closes before the next case’s, so
removing it leaves the chain balanced.
pTr is the local point after modifiers. o.size_type.xyz carries the three
size fields and o.pos_rounding.w the rounding. The comment records what the
three size fields mean for this type — that is the contract step 6 fills.
3. The type ID
Section titled “3. The type ID”3D shapes are contiguous from 0; the next free is 22. 2D shapes are 30–32. Leave the gap, and do not reuse a retired ID.
4. The proxy envelope — vs in sdf.wgsl
Section titled “4. The proxy envelope — vs in sdf.wgsl”The vertex shader draws a unit cube per shape and scales it to bound the shape, so the fragment shader runs over the right pixels:
} else if (o.size_type.w > 9.5 && o.size_type.w < 10.5) { // Octahedron: vertices at ±size on each axis env = o.size_type.xxx;}env is the half-extent on each axis. The shader inflates it afterwards for
rounding, glow radius and modifier reach; you supply the bare shape.
Without a case the cube falls back to size + rounding. For an octahedron that
happens to be right. For a torus, a capsule or a link — anything whose extent
is bigger than its size fields — it is too small, and the shape draws with
its silhouette cut off.
5. The grid envelope — assign_objects in grid.wgsl
Section titled “5. The grid envelope — assign_objects in grid.wgsl” //#region SDF_TYPE [10] } else if (typeId < 10.5) { // Octahedron env = sz.xxx; //#endregion SDF_TYPE [10]Same value, different syntax. This one files the shape into the cells of the
spatial grid. Without it the shape falls back to sz and is filed into too few
cells — it draws correctly and vanishes from every scene-level effect:
outlines will not merge with it and neighbours will not see it.
The two envelope tables must agree. Over-estimating costs a little march time; under-estimating produces the two failures above. Round up.
6. The component — shapes/Octahedron.tsx
Section titled “6. The component — shapes/Octahedron.tsx”export type OctahedronProps = { position?: FlexibleVec3 /** Vertex distance from center (vertices at ±size on each axis). */ size?: number | PhotonValue<number> rotation?: FlexibleVec3 color?: string opacity?: number | PhotonValue<number> material?: Material onClick?: (shape: SDFState, e: any) => void onPointerOver?: (shape: SDFState) => void onPointerOut?: (shape: SDFState) => void}
export function Octahedron(props: OctahedronProps) { const { position, size = 0.5, rotation, color, opacity = 0.9, material = { ambient: 0.2 }, onClick, onPointerOver, onPointerOut } = props
const ctx = useFabric() const { shapesRef } = ctx const { outline, glow } = useEffects() // from an enclosing <Effect> const fast = useFast() // from an enclosing <Fast> const modifier = useModifier() // merged modifier frame const visibleGroup = useVisibleGroup() // from an enclosing <Visible> const id = nextShapeId()
const pos = toVec3(position) const rot = toRad(rotation)
effect(() => { const g = ctx.gpu() if (!g) return import('../render/sdfEngine').then(({ initSDF }) => { const live = ctx.gpuRef.current if (live) initSDF(live) })
shapesRef.current.set(id, { type: 10, position: pos, size: { x: size, y: 0, z: 0 } as Vec3, // ← the contract from step 2 rotation: rot, roundness: 0, color, opacity, material, outline: fast.enabled ? undefined : outline, glow: fast.enabled ? undefined : glow, fast: fast.enabled, fastAA: fast.enabled ? fast.aa : 0, modifier, visibleGroup, onClick, onPointerOver, onPointerOut, }) g.renderDirtyFrames = Math.max(g.renderDirtyFrames, 2)
const unsub = subscribeToSignals({ ...props, outline, glow, modifier }, () => { g.renderDirtyFrames = Math.max(g.renderDirtyFrames, 2) }) cleanup(() => { unsub() shapesRef.current.delete(id) g.renderDirtyFrames = Math.max(g.renderDirtyFrames, 2) }) })
return null as unknown as Node}Four things to get right in it:
- Its own size vocabulary. Users write
<Octahedron size={0.5}>; the component maps that intosize.xyzexactly as step 2’s comment says — here{ x: size, y: 0, z: 0 }. A cylinder mapsradiusandheight; a torus mapsradiusandtube. The prop names are the shape’s; the three floats are the shader’s. type: 10,as a literal. The build reads that exact text from each shape component to learn which region number belongs to which tag. A computed value is not seen, so the tag maps to nothing and the shape’s regions are never stripped.- Read every composer stack.
useEffects,useFast,useModifier,useVisibleGroup— reading them at registration is what makes<Effect>,<Fast>, the modifiers and<Visible>work on this shape with no further code. - Subscribe, register, clean up.
subscribeToSignalssubscribes to every reactive prop so a change marks the frame dirty;cleanupunregisters.
Export it from shapes/index.ts.
7. Picking and stripping
Section titled “7. Picking and stripping”Picking. pickShape in core/interact.ts raymarches on the CPU for click
and hover, with a small distance function per type:
case 10: d = sdOctahedron(pLocal, sz.x); break;A bounding sphere is acceptable — types 13 to 21 use one — since picking only needs the silhouette roughly right. Without a case the shape can never be picked.
Stripping. The three SDF_TYPE [10] regions from steps 1, 2 and 5 are
what the build removes: when <Octahedron appears nowhere in the app’s
source, all three leave. The envelope case in vs is not a region and always
ships.
TYPE_TO_FNS in the shake plugin feeds only the older chunk pass, which
consumers do not run — see Lazy subsystems.
An entry there changes nothing today.
What you do not touch
Section titled “What you do not touch”updateSDFBuffers, the pipelines, the renderer, the record layout. A shape registers a
record and everything downstream of that is generic.