('model_centered')
const [stockPositionOffset, setStockPositionOffset] = useState(0)
const stockSize = useMemo(
@@ -346,69 +363,146 @@ const App = () => {
{part.hint}
Stock: {' '}
- {stockSize
- .toArray()
- .map((value) => value.toFixed(2))
- .join(' × ')}{' '}
- mm (X × Y × Z)
+ {stockSource === 'glb'
+ ? `${stockMeshSize}.00 × ${stockMeshSize}.00 × ${stockMeshSize}.00 mm (services-encoded GLB blank)`
+ : stockShape === 'cylinder'
+ ? `⌀${stockCylinder.diameter.toFixed(2)} × ${stockCylinder.length.toFixed(2)} mm (diameter × length)`
+ : `${stockSize
+ .toArray()
+ .map((value) => value.toFixed(2))
+ .join(' × ')} mm (X × Y × Z)`}
- Stock X dimension (mm)
- {
- const value = event.target.valueAsNumber
- if (Number.isFinite(value) && value > 0 && value <= 1000)
- setStockDimensions((current) => ({ ...current, x: value }))
- }}
- />
-
-
- Stock Y dimension (mm)
- {
- const value = event.target.valueAsNumber
- if (Number.isFinite(value) && value > 0 && value <= 1000)
- setStockDimensions((current) => ({ ...current, y: value }))
- }}
- />
-
-
- Stock Z dimension (mm)
- {
- const value = event.target.valueAsNumber
- if (Number.isFinite(value) && value > 0 && value <= 1000)
- setStockDimensions((current) => ({ ...current, z: value }))
- }}
- />
-
-
- Stock position
+ Stock source
setStockPosition(event.target.value as StockPosition)}
+ value={stockSource}
+ onChange={(event) => setStockSource(event.target.value as StockSource)}
>
- Model centered
- Offset from top
- Offset from bottom
+ Shape preview
+ GLB fixture
- {stockPosition !== 'model_centered' ? (
+ {stockSource === 'geometry' ? (
+
+ Stock shape
+ setStockShape(event.target.value as StockShape)}
+ >
+ Box
+ Cylinder
+
+
+ ) : null}
+ {stockSource === 'glb' ? (
+
+ GLB blank
+ setStockMeshSize(event.target.value)}
+ >
+ 40 mm box
+ 60 mm box
+
+
+ ) : null}
+ {stockSource === 'geometry' && stockShape === 'cylinder' ? (
+ <>
+
+ Stock diameter (mm)
+ {
+ const value = event.target.valueAsNumber
+ if (Number.isFinite(value) && value > 0 && value <= 1000)
+ setStockCylinder((current) => ({ ...current, diameter: value }))
+ }}
+ />
+
+
+ Stock length (mm)
+ {
+ const value = event.target.valueAsNumber
+ if (Number.isFinite(value) && value > 0 && value <= 1000)
+ setStockCylinder((current) => ({ ...current, length: value }))
+ }}
+ />
+
+ >
+ ) : null}
+ {stockSource === 'geometry' && stockShape === 'box' ? (
+ <>
+
+ Stock X dimension (mm)
+ {
+ const value = event.target.valueAsNumber
+ if (Number.isFinite(value) && value > 0 && value <= 1000)
+ setStockDimensions((current) => ({ ...current, x: value }))
+ }}
+ />
+
+
+ Stock Y dimension (mm)
+ {
+ const value = event.target.valueAsNumber
+ if (Number.isFinite(value) && value > 0 && value <= 1000)
+ setStockDimensions((current) => ({ ...current, y: value }))
+ }}
+ />
+
+
+ Stock Z dimension (mm)
+ {
+ const value = event.target.valueAsNumber
+ if (Number.isFinite(value) && value > 0 && value <= 1000)
+ setStockDimensions((current) => ({ ...current, z: value }))
+ }}
+ />
+
+ >
+ ) : null}
+ {stockSource === 'geometry' ? (
+
+ Stock position
+ setStockPosition(event.target.value as StockPosition)}
+ >
+ Model centered
+ Offset from top
+ Offset from bottom
+
+
+ ) : null}
+ {stockSource === 'geometry' && stockPosition !== 'model_centered' ? (
Position offset (mm)
{
) : null}
- Fixed box stock dimensions and position. Values are millimetres.
+ {stockSource === 'glb'
+ ? 'Synthetic initial stock encoded by the services GLB writer, in part coordinates.'
+ : 'Fixed stock shape, dimensions and position. Values are millimetres.'}
{
}}
onPick={(pick: PartPick) => setSelected([...pick.ranked])}
/>
- {showStock ? (
-
+ ) : null}
+ {showStock && stockSource === 'glb' ? (
+
+ ) : null}
+ {showStock && stockSource === 'geometry' && stockShape === 'box' ? (
+
) : null}
Buffer.compare(focused, await canvas.screenshot())).not.toBe(0)
})
+ test(`cylinder stock mounts cleanly and fits with the part (${projection})`, async ({ page }) => {
+ const { canvas } = await openViewer(
+ page,
+ `projection=${projection}&stock=on&stockShape=cylinder`,
+ )
+ const stock = page.locator('p', { hasText: 'Stock:' })
+ await expect(stock).toContainText('⌀38.10 × 25.91')
+ const before = await readCamera(page)
+ const narrow = await canvas.screenshot()
+ await page.getByRole('spinbutton', { name: 'Stock diameter (mm)' }).fill('60')
+ await expect(stock).toContainText('⌀60.00 × 25.91')
+ await expect.poll(async () => Buffer.compare(narrow, await canvas.screenshot())).not.toBe(0)
+ await page.getByRole('button', { name: 'Fit', exact: true }).click()
+ await expect
+ .poll(async () => (await readCamera(page)).distance)
+ .toBeGreaterThan(before.distance)
+ })
+
+ test(`GLB stock loads, remounts and fits without intercepting picks (${projection})`, async ({
+ page,
+ }) => {
+ const errors: string[] = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text())
+ })
+ const { canvas, box } = await openViewer(page, `projection=${projection}&stockSource=glb`)
+ await expect(page.getByRole('combobox', { name: 'Stock source' })).toHaveValue('glb')
+ await expect(page.getByRole('combobox', { name: 'Stock shape' })).toHaveCount(0)
+ const before = await readCamera(page)
+ const withoutStock = await canvas.screenshot()
+ await page.getByRole('button', { name: 'Show stock', exact: true }).click()
+ await expect
+ .poll(async () => Buffer.compare(withoutStock, await canvas.screenshot()))
+ .not.toBe(0)
+ expect((await readCamera(page)).distance).toBeCloseTo(before.distance, 5)
+ await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) })
+ await expect(page.locator('p', { hasText: 'Selected:' })).toContainText(
+ projection === 'perspective' ? 'back-face' : 'front-face',
+ )
+ await page.getByRole('button', { name: 'Fit', exact: true }).click()
+ await expect
+ .poll(async () => (await readCamera(page)).distance)
+ .toBeGreaterThan(before.distance)
+
+ const smallDistance = (await readCamera(page)).distance
+ const smallStock = await canvas.screenshot()
+ await page.getByRole('combobox', { name: 'GLB blank' }).selectOption('60')
+ await expect.poll(async () => Buffer.compare(smallStock, await canvas.screenshot())).not.toBe(0)
+ await page.getByRole('button', { name: 'Fit', exact: true }).click()
+ await expect.poll(async () => (await readCamera(page)).distance).toBeGreaterThan(smallDistance)
+
+ // Stock cleans up decoded geometry; the caller's bytes remain reusable on remount.
+ await page.getByRole('button', { name: 'Hide stock', exact: true }).click()
+ await page.getByRole('button', { name: 'Reset', exact: true }).click()
+ await expect.poll(async () => (await readCamera(page)).distance).toBeCloseTo(before.distance, 5)
+ const hidden = await canvas.screenshot()
+ await page.getByRole('button', { name: 'Show stock', exact: true }).click()
+ await expect.poll(async () => Buffer.compare(hidden, await canvas.screenshot())).not.toBe(0)
+ expect(errors).toEqual([])
+ })
+
test(`stock present on mount does not move the section midpoint (${projection})`, async ({
page,
}) => {
diff --git a/packages/viewer/README.md b/packages/viewer/README.md
index 81d7690..3e17387 100644
--- a/packages/viewer/README.md
+++ b/packages/viewer/README.md
@@ -76,7 +76,7 @@ What each wrapper is for:
├─ validates the report, loads the mesh, then renders
│ └─ draws the part and handles hover, click, colours, section cuts
├─ arrows for the directions the part can be machined from
- ├─ translucent stock, included when fitting the camera
+ ├─ translucent geometry, GLB, box or cylinder stock, included in Fit
├─ optional: click a face or a plane to cut the part open
├─ optional: click two points for a distance, three for an angle
├─ reference geometry, sized to the part
@@ -146,9 +146,8 @@ context rather than a fact of the part surface.
### Stock and display controls
-Use `` for an axis-aligned blank around a part, or ` `
-for an actual stock mesh, including cylindrical or irregular blanks. Stock and part coordinates
-must use the same millimetre, Z-up frame. Both components include stock in Fit and Reset while
+Use `` for a box, cylinder, prepared geometry or GLB bytes. Stock and part coordinates
+must use the same millimetre, Z-up frame. Stock is included in Fit and Reset while
keeping section tools, measurements, the grid, and direction arrows sized to the finished part.
Stock does not intercept clicks or get clipped by the part's section plane.
@@ -156,7 +155,7 @@ Stock does not intercept clicks or get clipped by the part's section plane.
import { useMemo, useRef, useState } from 'react'
import {
Axes,
- BoxStock,
+ Stock,
DirectionArrows,
PartMesh,
Viewer,
@@ -187,7 +186,7 @@ export function StockPreview({ model, geometry }: { model: PartModel; geometry:
regionHighlights={colors}
activeDirection={directions ? direction : null}
/>
- {stock && }
+ {stock && }
{axes && }
` draws round stock along any axis:
+`origin` is the centre of its base and `axis` points from the base toward its far end, in part
+coordinates. For a preview, pass
+`cylinder={{ partGeometry, diameter, length, position, positionOffset }}` instead. It stands along
+the part's Z, is centred on the part's bounding box, and is placed along Z by the same `position`
+rule as fixed-box stock. The kernel centres fixed round stock on the part's
+smallest enclosing circle about the first setup's cutting direction, so render the resolved stock
+once a job is planned.
+
+` ` draws a block whose corners are given in a frame
+of `location`, `axis` and `refDirection`: a point `(x, y, z)` sits at
+`location + x·refDirection + y·(axis × refDirection) + z·axis`, with `axis` normalised and
+`refDirection` projected square to it and normalised. That is the shape of an Engine plan's
+`stock.resolved` box, which is squared to the setup rather than to the part. Omit `frame` when
+`lower` and `upper` are already in part coordinates. Resolved placement and `partGeometry`
+preview options are mutually exclusive for both shapes.
+
+Box and cylinder sources rebuild their mesh only when their figures/options change (or the
+preview's `partGeometry` reference changes). Inline option objects do not trigger repeated part
+scans on ordinary renders. Figures that cannot make a
+solid — a zero axis, a non-positive size, a frame whose axis and reference direction are parallel —
+throw `RangeError`.
+
+`Stock` accepts exactly one of `geometry`, `glb`, `box` or `cylinder`:
+
+```tsx
+
+// Or:
+
+
+
+
+
+```
+
+All four inputs are mutually exclusive in `StockProps`. With `geometry`, the caller owns its
+lifetime and disposal. With `box` or `cylinder`, `Stock` owns the generated geometry. With `glb`,
+`Stock` decodes the bytes internally and disposes decoded
+geometry when the bytes change or the component unmounts. Keep the same `ArrayBuffer` reference
+while displaying the same artifact to avoid decoding it again on ordinary renders.
+It renders nothing while decoding and throws decoding failures to the nearest error boundary.
+Changing the source immediately hides the previous stock; late results from an earlier decode
+are discarded and cleaned up.
+
+The component does not fetch, cache or store artifacts. The caller supplies the bytes.
+GLB node transforms are applied without centering, rescaling or changing axes:
+Engine stock artifacts are already in part coordinates, millimetres and Z-up. Indexed geometry
+stays indexed; missing normals are computed without requiring a part report. The parser cleans up
+its temporary scene after preparing the returned geometry.
+
+Engine stock files are plain GLB served with HTTP gzip, which the browser handles automatically;
+no meshopt decoder is needed. Authentication, durable artifact copies, action selection and
+missing-artifact fallback and artifact caching belong to the application. The example's
+**Stock source → GLB fixture** option exercises synthetic blanks encoded by the services stock
+writer. **Shape preview** exposes the separate box/cylinder shape controls.
+
+`Stock` accepts `color`, `emissive` (default black), `flatShading` (default `false`),
+`opacity` (default `0.2`), `edgeColor`, `edgeOpacity`, and `showEdges`. Emissive shading
+and flat shading change only the material, not the source's vertices, indices or normals.
+`renderStyle="legacy-workpiece"` reproduces legacy Workpiece rendering: white with dark emissive
+shading, per-face shading, 20% opacity, two shared-geometry surface passes and black 50%-opacity
+edges drawn before surfaces (rather than over them). The default `overlay` style is unchanged.
+
+For action-to-action playback, `retainPrevious` keeps the current decoded GLB visible until
+the next GLB is ready, then swaps meshes without a blank frame. Key the stock's parent by
+part/calculation identity (not action) to prevent retaining geometry across unrelated runs.
+The default still hides the previous source during decoding. Geometry is disposed after a
+replacement commits; cancelled/late results are discarded.
+The outline is built the first time it is shown, so a large stock mesh with `showEdges={false}`
+costs no edge pass. All source types accept the same appearance props. Caller-provided geometry is never disposed; the
components dispose their own materials and outlines. Conditionally mount stock to toggle it.
Toggling stock preserves the camera; Fit/Reset then frames the visible stock together with the part.
+**Migration from 1.x:** `BoxStock` and `BoxStockProps` have been removed. Use
+` ` for allowance-based previews, or
+` ` for fixed
+dimensions. No preview-builder helper is required. `Stock geometry={geometry}` remains supported.
+`StockProps` is now a union type alias, so `interface X extends StockProps` becomes
+`type X = StockProps & { … }`; `StockAppearanceProps` names the shared appearance props alone.
+
`directionHighlights(model, activeDirection?)` returns region colors in the same palette as
`DirectionArrows`. For a face with several owners, the most specific feature wins, followed by
candidate-direction order and feature tag. Unmatched directions are left unpainted. Hover and
diff --git a/packages/viewer/fixtures/mesh/stock-box.json b/packages/viewer/fixtures/mesh/stock-box.json
new file mode 100644
index 0000000..e3b6660
--- /dev/null
+++ b/packages/viewer/fixtures/mesh/stock-box.json
@@ -0,0 +1,5 @@
+{
+ "description": "Synthetic 40 mm and 60 mm initial-stock boxes centered on the origin. Encoded with services/apps/engine-worker/src/lib/mesh.ts meshToGlb on 2026-10-01; the same POSITION-only indexed GLB writer used by stock artifacts. Not captured kernel calculations. Base64 stores the exact GLB bytes for unit tests and the browser example.",
+ "largeBase64": "Z2xURgIAAAAkAwAAGAIAAEpTT057ImFzc2V0Ijp7InZlcnNpb24iOiIyLjAiLCJnZW5lcmF0b3IiOiJlbmdpbmUtd29ya2VyIn0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjI0MH1dLCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjowLCJieXRlT2Zmc2V0IjowLCJieXRlTGVuZ3RoIjo5NiwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVPZmZzZXQiOjk2LCJieXRlTGVuZ3RoIjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYWNjZXNzb3JzIjpbeyJidWZmZXJWaWV3IjowLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6OCwidHlwZSI6IlZFQzMiLCJtaW4iOlstMzAsLTMwLC0zMF0sIm1heCI6WzMwLDMwLDMwXX0seyJidWZmZXJWaWV3IjoxLCJjb21wb25lbnRUeXBlIjo1MTI1LCJjb3VudCI6MzYsInR5cGUiOiJTQ0FMQVIifV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjR9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XSwic2NlbmUiOjB9IPAAAABCSU4AAADwwQAA8MEAAPDBAADwQQAA8MEAAPDBAADwQQAA8EEAAPDBAADwwQAA8EEAAPDBAADwwQAA8MEAAPBBAADwQQAA8MEAAPBBAADwQQAA8EEAAPBBAADwwQAA8EEAAPBBAAAAAAIAAAABAAAAAAAAAAMAAAACAAAABAAAAAUAAAAGAAAABAAAAAYAAAAHAAAAAAAAAAEAAAAFAAAAAAAAAAUAAAAEAAAAAQAAAAIAAAAGAAAAAQAAAAYAAAAFAAAAAgAAAAMAAAAHAAAAAgAAAAcAAAAGAAAAAwAAAAAAAAAEAAAAAwAAAAQAAAAHAAAA",
+ "base64": "Z2xURgIAAAAkAwAAGAIAAEpTT057ImFzc2V0Ijp7InZlcnNpb24iOiIyLjAiLCJnZW5lcmF0b3IiOiJlbmdpbmUtd29ya2VyIn0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjI0MH1dLCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjowLCJieXRlT2Zmc2V0IjowLCJieXRlTGVuZ3RoIjo5NiwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVPZmZzZXQiOjk2LCJieXRlTGVuZ3RoIjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYWNjZXNzb3JzIjpbeyJidWZmZXJWaWV3IjowLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6OCwidHlwZSI6IlZFQzMiLCJtaW4iOlstMjAsLTIwLC0yMF0sIm1heCI6WzIwLDIwLDIwXX0seyJidWZmZXJWaWV3IjoxLCJjb21wb25lbnRUeXBlIjo1MTI1LCJjb3VudCI6MzYsInR5cGUiOiJTQ0FMQVIifV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjR9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XSwic2NlbmUiOjB9IPAAAABCSU4AAACgwQAAoMEAAKDBAACgQQAAoMEAAKDBAACgQQAAoEEAAKDBAACgwQAAoEEAAKDBAACgwQAAoMEAAKBBAACgQQAAoMEAAKBBAACgQQAAoEEAAKBBAACgwQAAoEEAAKBBAAAAAAIAAAABAAAAAAAAAAMAAAACAAAABAAAAAUAAAAGAAAABAAAAAYAAAAHAAAAAAAAAAEAAAAFAAAAAAAAAAUAAAAEAAAAAQAAAAIAAAAGAAAAAQAAAAYAAAAFAAAAAgAAAAMAAAAHAAAAAgAAAAcAAAAGAAAAAwAAAAAAAAAEAAAAAwAAAAQAAAAHAAAA"
+}
diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts
index 4524fc2..f6301ba 100644
--- a/packages/viewer/src/index.ts
+++ b/packages/viewer/src/index.ts
@@ -13,11 +13,20 @@ export type {
ViewerToolbarControlsProps,
ViewerToolbarProps,
} from './viewer-toolbar.js'
-export { Stock, BoxStock } from './stock.js'
-export type { StockProps, BoxStockProps } from './stock.js'
+export { Stock } from './stock.js'
+export type { StockProps, StockSource, StockAppearanceProps } from './stock.js'
export { Banana, BANANA_MODEL_URL } from './banana.js'
export { boxStockBounds, fixedBoxStockBounds } from './render/stock.js'
-export type { StockAllowance, StockPosition } from './render/stock.js'
+export type {
+ CylinderStockFigure,
+ BoxStockFigure,
+ BoxStockInput,
+ BoxStockPreview,
+ CylinderStockInput,
+ CylinderStockPreview,
+ StockAllowance,
+ StockPosition,
+} from './render/stock.js'
export { directionHighlights } from './render/direction-highlights.js'
export type { PartDisplay } from './render/part.js'
export { Axes, Grid, ViewCube } from './primitives.js'
diff --git a/packages/viewer/src/render/stock-mesh-buffer.ts b/packages/viewer/src/render/stock-mesh-buffer.ts
new file mode 100644
index 0000000..fa9459c
--- /dev/null
+++ b/packages/viewer/src/render/stock-mesh-buffer.ts
@@ -0,0 +1,50 @@
+import type { BufferGeometry } from 'three'
+
+/** Own decoded meshes until their replacement has committed, including cancelled/late decodes. */
+export function createStockMeshBuffer() {
+ const owned = new Set()
+ let disposed = false
+ return {
+ request(
+ decode: () => Promise,
+ ready: (geometries: BufferGeometry[]) => void,
+ failed: (cause: unknown) => void,
+ ) {
+ // React StrictMode replays setup after cleanup on the same component instance.
+ disposed = false
+ let active = true
+ void decode().then(
+ (geometries) => {
+ if (!active || disposed) {
+ geometries.forEach((geometry) => geometry.dispose())
+ return
+ }
+ geometries.forEach((geometry) => owned.add(geometry))
+ ready(geometries)
+ },
+ (cause: unknown) => {
+ if (active && !disposed) {
+ failed(cause)
+ }
+ },
+ )
+ return () => {
+ active = false
+ }
+ },
+ commit(geometries: BufferGeometry[]) {
+ const visible = new Set(geometries)
+ for (const geometry of owned) {
+ if (!visible.has(geometry)) {
+ owned.delete(geometry)
+ geometry.dispose()
+ }
+ }
+ },
+ dispose() {
+ disposed = true
+ owned.forEach((geometry) => geometry.dispose())
+ owned.clear()
+ },
+ }
+}
diff --git a/packages/viewer/src/render/stock-mesh.ts b/packages/viewer/src/render/stock-mesh.ts
new file mode 100644
index 0000000..0ce3c45
--- /dev/null
+++ b/packages/viewer/src/render/stock-mesh.ts
@@ -0,0 +1,39 @@
+import { type BufferGeometry, Mesh, type Object3D } from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+
+/**
+ * Decode caller-provided GLB bytes into stock geometry in part coordinates.
+ * Internal to Stock: no fetching or caching. Stock owns the returned geometry
+ * and disposes it when its input changes or it unmounts.
+ */
+export async function parseStockGlb(data: ArrayBuffer): Promise {
+ const { scene } = await new GLTFLoader().parseAsync(data, '')
+ try {
+ return stockMeshGeometries(scene)
+ } finally {
+ // This scene is private to the parser, not a shared loader-cache resource.
+ scene.traverse((object) => {
+ if (!(object instanceof Mesh)) return
+ object.geometry.dispose()
+ for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
+ material.dispose()
+ }
+ })
+ }
+}
+
+/** Prepare owned stock geometry without changing the loader's shared scene. */
+export function stockMeshGeometries(scene: Object3D): BufferGeometry[] {
+ const geometries: BufferGeometry[] = []
+ scene.updateWorldMatrix(true, true)
+ scene.traverse((object) => {
+ if (!(object instanceof Mesh)) return
+ const geometry = object.geometry.clone().applyMatrix4(object.matrixWorld)
+ // Engine stock GLBs contain indexed positions and no normals. They need
+ // lighting, but not the part report's per-region attributes or de-indexing.
+ if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals()
+ geometries.push(geometry)
+ })
+ if (geometries.length === 0) throw new Error('The stock GLB contains no mesh.')
+ return geometries
+}
diff --git a/packages/viewer/src/render/stock.ts b/packages/viewer/src/render/stock.ts
index 3721007..65444bd 100644
--- a/packages/viewer/src/render/stock.ts
+++ b/packages/viewer/src/render/stock.ts
@@ -1,12 +1,16 @@
import {
Box3,
+ BoxGeometry,
type BufferGeometry,
+ CylinderGeometry,
EdgesGeometry,
Group,
LineBasicMaterial,
LineSegments,
+ Matrix4,
Mesh,
MeshLambertMaterial,
+ Quaternion,
Vector3,
} from 'three'
import type { Vec3 } from '../model/types.js'
@@ -21,9 +25,87 @@ import { STOCK_OBJECT } from './camera.js'
*/
export type StockAllowance = number | Vec3 | { wall: number; floor: number }
-/** How an explicit box is positioned relative to the part's Z bounds. */
+/** How explicit fixed stock is positioned relative to the part's Z bounds. */
export type StockPosition = 'model_centered' | 'offset_from_top' | 'offset_from_bottom'
+/**
+ * Round stock in part coordinates, in millimetres: the centre of its base, the
+ * direction from the base toward its far end, and its size. The Engine's
+ * resolved `StockCylinder` carries the same four figures.
+ */
+export interface CylinderStockFigure {
+ origin: Vec3
+ axis: Vec3
+ diameter: number
+ length: number
+}
+
+/**
+ * A block squared to a frame of its own rather than to the part, in
+ * millimetres. `lower` and `upper` are corners in `frame`, whose point
+ * `(x, y, z)` sits at `location + x·refDirection + y·(axis × refDirection) + z·axis`
+ * in part coordinates, with `axis` normalised and `refDirection` projected
+ * square to it and normalised. This is the shape of the Engine's plan
+ * `StockBox`, which is squared to the setup.
+ */
+export interface BoxStockFigure {
+ /** Omit for a box whose corners are already in part coordinates. */
+ frame?: { location: Vec3; axis: Vec3; refDirection: Vec3 }
+ lower: Vec3
+ upper: Vec3
+}
+
+/** Box preview around the part: explicit dimensions or per-side allowance. */
+export type BoxStockPreview = {
+ partGeometry: BufferGeometry
+ offset?: Vec3
+} & (
+ | { dimensions: Vec3; position?: StockPosition; positionOffset?: number; allowance?: never }
+ | { allowance?: StockAllowance; dimensions?: never; position?: never; positionOffset?: never }
+)
+
+/** Resolved corners or part-relative preview options, never both. */
+export type BoxStockInput =
+ | (BoxStockFigure & {
+ partGeometry?: never
+ offset?: never
+ allowance?: never
+ dimensions?: never
+ position?: never
+ positionOffset?: never
+ })
+ | (BoxStockPreview & { lower?: never; upper?: never; frame?: never })
+
+/** Upright cylinder preview centered on the part's bounding box. */
+export interface CylinderStockPreview {
+ partGeometry: BufferGeometry
+ diameter: number
+ length: number
+ position?: StockPosition
+ positionOffset?: number
+}
+
+/** Resolved cylinder placement or part-relative preview options, never both. */
+export type CylinderStockInput =
+ | (CylinderStockFigure & { partGeometry?: never; position?: never; positionOffset?: never })
+ | (CylinderStockPreview & { origin?: never; axis?: never })
+
+function boxFigure(bounds: Box3): BoxStockFigure {
+ return {
+ lower: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
+ upper: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
+ }
+}
+
+/**
+ * Sides on a cylinder's rings. Adjacent sides meet at under 6°, well inside the
+ * outline's 15° crease, so the outline draws the two rims and no facet lines.
+ */
+const CYLINDER_SEGMENTS = 64
+
+/** three.js builds a cylinder about +Y, centred on the origin. */
+const CYLINDER_AXIS = new Vector3(0, 1, 0)
+
/**
* Bounds for an explicit fixed box. X/Y are centered on the part's bounding
* box; Z is centered, offset from the top, or offset from the bottom. `offset`
@@ -50,19 +132,152 @@ export function fixedBoxStockBounds(
}
const part = partBounds(geometry)
const center = part.getCenter(new Vector3())
- const z =
- position === 'offset_from_top'
- ? part.max.z + positionOffset - dimensions.z / 2
- : position === 'offset_from_bottom'
- ? part.min.z - positionOffset + dimensions.z / 2
- : center.z
- const half = new Vector3(dimensions.x / 2, dimensions.y / 2, dimensions.z / 2)
+ const z = fixedStockLowerZ(part, dimensions.z, position, positionOffset)
return new Box3(
- new Vector3(center.x, center.y, z).sub(half),
- new Vector3(center.x, center.y, z).add(half),
+ new Vector3(center.x - dimensions.x / 2, center.y - dimensions.y / 2, z),
+ new Vector3(center.x + dimensions.x / 2, center.y + dimensions.y / 2, z + dimensions.z),
).translate(new Vector3(offset.x, offset.y, offset.z))
}
+/**
+ * A preview of fixed round stock: `diameter` across and `length` long, standing
+ * along the part's Z, centred on the part's bounding box across it, and placed
+ * along it by the same position rule as {@link fixedBoxStockBounds}.
+ *
+ * It is a preview, as the legacy app's was. The kernel stands fixed round stock
+ * along the first setup's cutting direction and centres it on the part's
+ * smallest enclosing circle, which the bounding-box centre only approximates.
+ * Once a job is planned, render the stock it resolved instead.
+ */
+function fixedCylinderStock(
+ geometry: BufferGeometry,
+ size: { diameter: number; length: number },
+ position: StockPosition = 'model_centered',
+ positionOffset = 0,
+): CylinderStockFigure {
+ if (![size.diameter, size.length].every((value) => Number.isFinite(value) && value > 0)) {
+ throw new RangeError('Fixed stock diameter and length must be finite and positive.')
+ }
+ if (!Number.isFinite(positionOffset)) {
+ throw new RangeError('Fixed stock position offset must be finite.')
+ }
+ const part = partBounds(geometry)
+ const center = part.getCenter(new Vector3())
+ return {
+ origin: {
+ x: center.x,
+ y: center.y,
+ z: fixedStockLowerZ(part, size.length, position, positionOffset),
+ },
+ axis: { x: 0, y: 0, z: 1 },
+ diameter: size.diameter,
+ length: size.length,
+ }
+}
+
+/**
+ * Where fixed stock `length` long starts along Z. Centred splits the extra
+ * length evenly and ignores the offset; from the top puts the stock's top
+ * `positionOffset` above the part's; from the bottom puts its bottom
+ * `positionOffset` below the part's. The kernel places both fixed shapes by
+ * this one rule, so both previews share it too.
+ */
+function fixedStockLowerZ(
+ part: Box3,
+ length: number,
+ position: StockPosition,
+ positionOffset: number,
+): number {
+ if (position === 'offset_from_top') return part.max.z + positionOffset - length
+ if (position === 'offset_from_bottom') return part.min.z - positionOffset
+ return (part.min.z + part.max.z - length) / 2
+}
+
+/** A closed cylinder for `figure`, in part coordinates. */
+export function cylinderStockGeometry(input: CylinderStockInput): BufferGeometry {
+ const figure: CylinderStockFigure = input.partGeometry
+ ? fixedCylinderStock(input.partGeometry, input, input.position, input.positionOffset)
+ : input
+ const { origin, diameter, length } = figure
+ if (![origin.x, origin.y, origin.z].every(Number.isFinite)) {
+ throw new RangeError('Cylinder stock origin must be finite.')
+ }
+ if (![diameter, length].every((value) => Number.isFinite(value) && value > 0)) {
+ throw new RangeError('Cylinder stock diameter and length must be finite and positive.')
+ }
+ const axis = new Vector3(figure.axis.x, figure.axis.y, figure.axis.z)
+ const norm = axis.length()
+ if (!Number.isFinite(norm) || norm === 0) {
+ throw new RangeError('Cylinder stock axis must be finite and non-zero.')
+ }
+ axis.divideScalar(norm)
+ const center = axis
+ .clone()
+ .multiplyScalar(length / 2)
+ .add(new Vector3(origin.x, origin.y, origin.z))
+ return new CylinderGeometry(diameter / 2, diameter / 2, length, CYLINDER_SEGMENTS)
+ .applyQuaternion(new Quaternion().setFromUnitVectors(CYLINDER_AXIS, axis))
+ .translate(center.x, center.y, center.z)
+}
+
+/** Resolve preview options when supplied, then build a box in part coordinates. */
+export function boxStockGeometry(input: BoxStockInput): BufferGeometry {
+ const figure: BoxStockFigure = input.partGeometry
+ ? boxFigure(
+ input.dimensions
+ ? fixedBoxStockBounds(
+ input.partGeometry,
+ input.dimensions,
+ input.position,
+ input.positionOffset,
+ input.offset,
+ )
+ : boxStockBounds(input.partGeometry, input.allowance, input.offset),
+ )
+ : input
+ return orientedBoxStockGeometry(figure)
+}
+
+/** A closed block for `figure`, in part coordinates. */
+export function orientedBoxStockGeometry(figure: BoxStockFigure): BufferGeometry {
+ const { lower, upper } = figure
+ const frame = figure.frame ?? {
+ location: { x: 0, y: 0, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ refDirection: { x: 1, y: 0, z: 0 },
+ }
+ const { location, axis, refDirection } = frame
+ if (
+ ![location, axis, refDirection, lower, upper].every((vector) =>
+ [vector.x, vector.y, vector.z].every(Number.isFinite),
+ )
+ ) {
+ throw new RangeError('Box stock frame and corners must be finite.')
+ }
+ const size = new Vector3(upper.x - lower.x, upper.y - lower.y, upper.z - lower.z)
+ if (size.toArray().some((value) => !(value > 0))) {
+ throw new RangeError('Box stock upper corner must be above its lower corner on every axis.')
+ }
+ // Directions, not lengths: as in a STEP placement, the axis is normalised and
+ // the reference direction is projected square to it, so a non-unit or
+ // slightly skewed frame neither scales nor shears the block.
+ const z = new Vector3(axis.x, axis.y, axis.z).normalize()
+ const x = new Vector3(refDirection.x, refDirection.y, refDirection.z)
+ const reference = x.length()
+ x.addScaledVector(z, -x.dot(z))
+ // Projection leaves float noise, not zero, when the two are parallel.
+ if (!(z.lengthSq() > 0 && x.length() > 1e-9 * reference)) {
+ throw new RangeError(
+ 'Box stock frame axis and reference direction must be non-zero and not parallel.',
+ )
+ }
+ x.normalize()
+ const y = new Vector3().crossVectors(z, x)
+ return new BoxGeometry(size.x, size.y, size.z)
+ .translate((lower.x + upper.x) / 2, (lower.y + upper.y) / 2, (lower.z + upper.z) / 2)
+ .applyMatrix4(new Matrix4().makeBasis(x, y, z).setPosition(location.x, location.y, location.z))
+}
+
/** Per-side allowance and centre offset in millimetres, in the part's coordinates. */
export function boxStockBounds(
geometry: BufferGeometry,
@@ -107,28 +322,46 @@ function partBounds(geometry: BufferGeometry): Box3 {
}
/** Owns the stock materials and outline, never the caller's geometry. */
-export function createStock(geometry: BufferGeometry) {
+export function createStock(geometry: BufferGeometry, legacyWorkpiece = false) {
const material = new MeshLambertMaterial({ transparent: true, depthWrite: false })
const edgeMaterial = new LineBasicMaterial({ transparent: true, depthWrite: false })
const mesh = new Mesh(geometry, material)
- const edgeGeometry = new EdgesGeometry(geometry, 15)
- const edges = new LineSegments(edgeGeometry, edgeMaterial)
const object = new Group()
object.userData[STOCK_OBJECT] = true
- mesh.renderOrder = 5
- edges.renderOrder = 6
+ mesh.renderOrder = legacyWorkpiece ? 2 : 5
mesh.raycast = () => {}
- edges.raycast = () => {}
- object.add(mesh, edges)
+ object.add(mesh)
+ if (legacyWorkpiece) {
+ // Legacy Workpiece draws stock and stockShadow with the same material.
+ // Share geometry/material rather than allocating a second copy of the GLB.
+ const shadow = mesh.clone()
+ shadow.raycast = () => {}
+ object.add(shadow)
+ edgeMaterial.depthWrite = true
+ }
+ let edges: LineSegments | null = null
return {
object,
material,
edgeMaterial,
- edges,
+ /**
+ * Shows or hides the outline, building it the first time it is shown: the
+ * edge pass reads every triangle, and stock cut by a toolpath has a great
+ * many of them.
+ */
+ showEdges(show: boolean) {
+ if (show && !edges) {
+ edges = new LineSegments(new EdgesGeometry(geometry, 15), edgeMaterial)
+ edges.renderOrder = legacyWorkpiece ? 1 : 6
+ edges.raycast = () => {}
+ object.add(edges)
+ }
+ if (edges) edges.visible = show
+ },
dispose() {
material.dispose()
edgeMaterial.dispose()
- edgeGeometry.dispose()
+ edges?.geometry.dispose()
},
}
}
diff --git a/packages/viewer/src/stock.tsx b/packages/viewer/src/stock.tsx
index f0fd4ab..9db0d9b 100644
--- a/packages/viewer/src/stock.tsx
+++ b/packages/viewer/src/stock.tsx
@@ -1,91 +1,205 @@
import { useThree } from '@react-three/fiber'
-import { useEffect, useLayoutEffect, useMemo } from 'react'
-import { type BufferGeometry, BoxGeometry, Vector3 } from 'three'
-import type { Vec3 } from './model/types.js'
+import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import type { BufferGeometry } from 'three'
+import { parseStockGlb } from './render/stock-mesh.js'
+import { createStockMeshBuffer } from './render/stock-mesh-buffer.js'
import {
- boxStockBounds,
+ type BoxStockInput,
+ boxStockGeometry,
createStock,
- fixedBoxStockBounds,
- type StockAllowance,
- type StockPosition,
+ cylinderStockGeometry,
+ type CylinderStockInput,
} from './render/stock.js'
-export interface StockProps {
- /** Caller-owned stock mesh in the same millimetre, Z-up coordinates as the part. */
- geometry: BufferGeometry
+export interface StockAppearanceProps {
+ /** Keep the preceding decoded GLB visible until its replacement is ready. Key by part/run to reset. */
+ retainPrevious?: boolean
+ /** Reproduce the legacy Workpiece's materials, duplicate surface pass and edge ordering. */
+ renderStyle?: 'overlay' | 'legacy-workpiece'
color?: number
+ /** Unlit surface contribution, useful for readable workpiece shading beside the CAD model. */
+ emissive?: number
+ /** Shade each triangle independently without changing the source's vertices or normals. */
+ flatShading?: boolean
opacity?: number
edgeColor?: number
edgeOpacity?: number
showEdges?: boolean
}
+interface StockSources {
+ /** Caller-owned geometry; Stock never disposes it. */
+ geometry: BufferGeometry
+ /** Caller-provided GLB bytes; Stock owns and cleans up the decoded geometry. */
+ glb: ArrayBuffer
+ /** Resolved box corners/frame, or part-relative box preview options. */
+ box: BoxStockInput
+ /** Resolved cylinder placement, or part-relative cylinder preview options. */
+ cylinder: CylinderStockInput
+}
+
+/** Exactly one source, in the same millimetre, Z-up coordinates as the part. */
+export type StockSource = {
+ [K in keyof StockSources]: Pick &
+ Partial, never>>
+}[keyof StockSources]
+
+export type StockProps = StockAppearanceProps & StockSource
+
/** Translucent stock, included in Fit but ignored by picking, sections and measurements. */
-export const Stock = ({
+export const Stock = ({ geometry, glb, box, cylinder, ...appearance }: StockProps) =>
+ geometry ? (
+
+ ) : glb ? (
+
+ ) : (
+
+ )
+
+/** One renderer for caller-owned geometry and internally decoded GLB meshes. */
+const GeometryStock = ({
geometry,
- color = 0xb9cbe2,
+ renderStyle = 'overlay',
+ color = renderStyle === 'legacy-workpiece' ? 0xffffff : 0xb9cbe2,
+ emissive = renderStyle === 'legacy-workpiece' ? 0x3c4051 : 0x000000,
+ flatShading = renderStyle === 'legacy-workpiece',
opacity = 0.2,
- edgeColor = 0xa8bdd8,
- edgeOpacity = 0.75,
+ edgeColor = renderStyle === 'legacy-workpiece' ? 0x000000 : 0xa8bdd8,
+ edgeOpacity = renderStyle === 'legacy-workpiece' ? 0.5 : 0.75,
showEdges = true,
-}: StockProps) => {
+}: StockAppearanceProps & { geometry: BufferGeometry }) => {
const invalidate = useThree((state) => state.invalidate)
- const stock = useMemo(() => createStock(geometry), [geometry])
+ const stock = useMemo(
+ () => createStock(geometry, renderStyle === 'legacy-workpiece'),
+ [geometry, renderStyle],
+ )
useEffect(() => () => stock.dispose(), [stock])
useLayoutEffect(() => {
stock.material.color.setHex(color)
+ stock.material.emissive.setHex(emissive)
+ if (stock.material.flatShading !== flatShading) {
+ stock.material.flatShading = flatShading
+ stock.material.needsUpdate = true
+ }
stock.material.opacity = opacity
+ stock.material.depthWrite = renderStyle === 'legacy-workpiece' && opacity === 1
+ stock.material.polygonOffset = renderStyle === 'legacy-workpiece' && opacity === 1
+ stock.material.polygonOffsetFactor = 1
+ stock.material.polygonOffsetUnits = 1
stock.edgeMaterial.color.setHex(edgeColor)
stock.edgeMaterial.opacity = edgeOpacity
- stock.edges.visible = showEdges
+ stock.showEdges(showEdges)
invalidate()
- }, [color, edgeColor, edgeOpacity, invalidate, opacity, showEdges, stock])
+ }, [
+ color,
+ emissive,
+ flatShading,
+ edgeColor,
+ edgeOpacity,
+ invalidate,
+ opacity,
+ renderStyle,
+ showEdges,
+ stock,
+ ])
return
}
-export interface BoxStockProps extends Omit {
- partGeometry: BufferGeometry
- /** Explicit X/Y/Z dimensions, in millimetres, for fixed-box stock. */
- dimensions?: Vec3
- /** Position mode for explicit fixed-box stock. Defaults to model-centered. */
- position?: StockPosition
- /** Distance from the selected top/bottom part bound, in millimetres. */
- positionOffset?: number
- /**
- * Stock left around the part, in millimetres. In the `{ wall, floor }` form,
- * wall applies to X/Y and floor applies to Z. Number and `{ x, y, z }` forms
- * are retained for compatibility. Defaults to zero.
- */
- allowance?: StockAllowance
- /** Translation from the part's bounding-box centre, in millimetres. */
- offset?: Vec3
+const GlbStock = ({
+ glb,
+ retainPrevious = false,
+ ...appearance
+}: StockAppearanceProps & { glb: ArrayBuffer }) => {
+ const buffer = useMemo(() => createStockMeshBuffer(), [])
+ const [decoded, setDecoded] = useState<{
+ source: ArrayBuffer
+ geometries?: BufferGeometry[]
+ error?: Error
+ } | null>(null)
+
+ useEffect(() => () => buffer.dispose(), [buffer])
+ useLayoutEffect(() => {
+ buffer.commit(decoded?.geometries ?? [])
+ }, [buffer, decoded])
+ useEffect(
+ () =>
+ buffer.request(
+ () => parseStockGlb(glb),
+ (geometries) => {
+ setDecoded({ source: glb, geometries })
+ },
+ (cause: unknown) => {
+ setDecoded({
+ source: glb,
+ error: cause instanceof Error ? cause : new Error(String(cause)),
+ })
+ },
+ ),
+ [buffer, glb],
+ )
+
+ // Retention is opt-in: playback can hold its current IPG through the next decode.
+ if (!decoded || (!retainPrevious && decoded.source !== glb)) return null
+ if (decoded.error) throw decoded.error
+ return decoded.geometries?.map((geometry) => (
+
+ ))
}
-/** An axis-aligned blank around the part. Use Stock for an arbitrary stock mesh. */
-export const BoxStock = ({
- partGeometry,
- dimensions,
- position = 'model_centered',
- positionOffset = 0,
- allowance = 0,
- offset,
- ...props
-}: BoxStockProps) => {
- const ox = offset?.x ?? 0
- const oy = offset?.y ?? 0
- const oz = offset?.z ?? 0
+/**
+ * JSON alone writes NaN and Infinity as null, which the builders would read as
+ * an absent value instead of rejecting with RangeError. No figure field is a
+ * string that could be mistaken for one of these.
+ */
+const keepNonFinite = (_key: string, value: unknown) =>
+ typeof value === 'number' && !Number.isFinite(value) ? String(value) : value
+const restoreNonFinite = (_key: string, value: unknown) =>
+ value === 'NaN' || value === 'Infinity' || value === '-Infinity' ? Number(value) : value
+
+/** Figure geometry is owned here; equivalent figures do not rebuild it. */
+const ShapeStock = ({
+ box,
+ cylinder,
+ ...appearance
+}: StockAppearanceProps & {
+ box?: BoxStockInput
+ cylinder?: CylinderStockInput
+}) => {
+ const isBox = box !== undefined
+ const partGeometry = box?.partGeometry ?? cylinder?.partGeometry
+ const figure = JSON.stringify(
+ box
+ ? box.partGeometry
+ ? {
+ dimensions: box.dimensions,
+ allowance: box.allowance,
+ offset: box.offset,
+ position: box.position,
+ positionOffset: box.positionOffset,
+ }
+ : { frame: box.frame, lower: box.lower, upper: box.upper }
+ : cylinder!.partGeometry
+ ? {
+ diameter: cylinder!.diameter,
+ length: cylinder!.length,
+ position: cylinder!.position,
+ positionOffset: cylinder!.positionOffset,
+ }
+ : {
+ origin: cylinder!.origin,
+ axis: cylinder!.axis,
+ diameter: cylinder!.diameter,
+ length: cylinder!.length,
+ },
+ keepNonFinite,
+ )
const geometry = useMemo(() => {
- const box = dimensions
- ? fixedBoxStockBounds(partGeometry, dimensions, position, positionOffset, {
- x: ox,
- y: oy,
- z: oz,
- })
- : boxStockBounds(partGeometry, allowance, { x: ox, y: oy, z: oz })
- const size = box.getSize(new Vector3())
- const center = box.getCenter(new Vector3())
- return new BoxGeometry(size.x, size.y, size.z).translate(center.x, center.y, center.z)
- }, [allowance, dimensions, ox, oy, oz, partGeometry, position, positionOffset])
+ const options = JSON.parse(figure, restoreNonFinite)
+ const input = partGeometry ? { ...options, partGeometry } : options
+ return isBox
+ ? boxStockGeometry(input as BoxStockInput)
+ : cylinderStockGeometry(input as CylinderStockInput)
+ }, [figure, isBox, partGeometry])
useEffect(() => () => geometry.dispose(), [geometry])
- return
+ return
}
diff --git a/packages/viewer/tests/stock-mesh-buffer.test.ts b/packages/viewer/tests/stock-mesh-buffer.test.ts
new file mode 100644
index 0000000..4ba7be3
--- /dev/null
+++ b/packages/viewer/tests/stock-mesh-buffer.test.ts
@@ -0,0 +1,102 @@
+import { BoxGeometry } from 'three'
+import type { BufferGeometry } from 'three'
+import { describe, expect, it, vi } from 'vitest'
+import { createStockMeshBuffer } from '../src/render/stock-mesh-buffer.js'
+
+const deferred = () => {
+ let resolve!: (geometries: BufferGeometry[]) => void
+ let reject!: (cause: unknown) => void
+ const promise = new Promise((yes, no) => {
+ resolve = yes
+ reject = no
+ })
+ return { promise, resolve, reject }
+}
+
+describe('decoded stock transitions', () => {
+ it('keeps A alive while B decodes and disposes A only after B commits', async () => {
+ const buffer = createStockMeshBuffer()
+ const a = new BoxGeometry()
+ const b = new BoxGeometry()
+ const disposeA = vi.spyOn(a, 'dispose')
+ const disposeB = vi.spyOn(b, 'dispose')
+ const ready = vi.fn()
+ const cancelA = buffer.request(() => Promise.resolve([a]), ready, vi.fn())
+ await Promise.resolve()
+ buffer.commit([a])
+ cancelA()
+ const pending = deferred()
+ buffer.request(() => pending.promise, ready, vi.fn())
+ expect(disposeA).not.toHaveBeenCalled()
+ pending.resolve([b])
+ await Promise.resolve()
+ expect(ready).toHaveBeenLastCalledWith([b])
+ expect(disposeA).not.toHaveBeenCalled()
+ buffer.commit([b])
+ expect(disposeA).toHaveBeenCalledOnce()
+ expect(disposeB).not.toHaveBeenCalled()
+ buffer.dispose()
+ expect(disposeB).toHaveBeenCalledOnce()
+ })
+
+ it('discards late B after A → B → A and never replaces the visible answer', async () => {
+ const buffer = createStockMeshBuffer()
+ const pending = deferred()
+ const b = new BoxGeometry()
+ const disposeB = vi.spyOn(b, 'dispose')
+ const ready = vi.fn()
+ const cancel = buffer.request(() => pending.promise, ready, vi.fn())
+ cancel()
+ pending.resolve([b])
+ await Promise.resolve()
+ expect(ready).not.toHaveBeenCalled()
+ expect(disposeB).toHaveBeenCalledOnce()
+ buffer.dispose()
+ })
+
+ it('disposes a decode completing after unmount', async () => {
+ const buffer = createStockMeshBuffer()
+ const pending = deferred()
+ const geometry = new BoxGeometry()
+ const dispose = vi.spyOn(geometry, 'dispose')
+ const ready = vi.fn()
+ buffer.request(() => pending.promise, ready, vi.fn())
+ buffer.dispose()
+ pending.resolve([geometry])
+ await Promise.resolve()
+ expect(ready).not.toHaveBeenCalled()
+ expect(dispose).toHaveBeenCalledOnce()
+ })
+
+ it('reports current decode errors but ignores cancelled failures', async () => {
+ const buffer = createStockMeshBuffer()
+ const failed = vi.fn()
+ const error = new Error('bad GLB')
+ buffer.request(() => Promise.reject(error), vi.fn(), failed)
+ await Promise.resolve()
+ expect(failed).toHaveBeenCalledWith(error)
+ const pending = deferred()
+ const cancel = buffer.request(() => pending.promise, vi.fn(), failed)
+ cancel()
+ pending.reject(error)
+ await Promise.resolve()
+ expect(failed).toHaveBeenCalledOnce()
+ buffer.dispose()
+ })
+
+ it('supports cleanup/setup replay without publishing an earlier request', async () => {
+ const buffer = createStockMeshBuffer()
+ const ready = vi.fn()
+ const first = deferred()
+ const cancel = buffer.request(() => first.promise, ready, vi.fn())
+ cancel()
+ buffer.dispose()
+ const current = new BoxGeometry()
+ buffer.request(() => Promise.resolve([current]), ready, vi.fn())
+ first.resolve([new BoxGeometry()])
+ await Promise.resolve()
+ expect(ready).toHaveBeenCalledOnce()
+ expect(ready).toHaveBeenCalledWith([current])
+ buffer.dispose()
+ })
+})
diff --git a/packages/viewer/tests/stock-mesh.test.ts b/packages/viewer/tests/stock-mesh.test.ts
new file mode 100644
index 0000000..a7c29bd
--- /dev/null
+++ b/packages/viewer/tests/stock-mesh.test.ts
@@ -0,0 +1,67 @@
+import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Vector3 } from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { describe, expect, it, vi } from 'vitest'
+import fixture from '../fixtures/mesh/stock-box.json'
+import { parseStockGlb, stockMeshGeometries } from '../src/render/stock-mesh.js'
+
+describe('GLB stock geometry', () => {
+ it('loads the services encoder output in millimetres without a report or decoder', async () => {
+ const bytes = Uint8Array.from(Buffer.from(fixture.base64, 'base64'))
+ const { scene } = await new GLTFLoader().parseAsync(bytes.buffer, '')
+ const source = scene.children[0] as Mesh
+ expect(source.geometry.hasAttribute('normal')).toBe(false)
+
+ const [geometry] = await parseStockGlb(bytes.buffer)
+ expect(geometry!.index?.count).toBe(36)
+ expect(geometry!.getAttribute('position').count).toBe(8)
+ expect(geometry!.hasAttribute('normal')).toBe(true)
+ geometry!.computeBoundingBox()
+ expect(geometry!.boundingBox!.min.toArray()).toEqual([-20, -20, -20])
+ expect(geometry!.boundingBox!.max.toArray()).toEqual([20, 20, 20])
+ expect(source.geometry.hasAttribute('normal')).toBe(false)
+ geometry!.dispose()
+ })
+
+ it('applies nested transforms to every mesh without changing shared source geometry', () => {
+ const source = new BoxGeometry(10, 20, 30)
+ const material = new MeshBasicMaterial()
+ const scene = new Group()
+ scene.position.set(5, 7, 9)
+ const nested = new Group()
+ nested.rotation.z = Math.PI / 2
+ const first = new Mesh(source, material)
+ first.position.x = 20
+ nested.add(first)
+ const second = new Mesh(source, material)
+ second.position.z = 50
+ scene.add(nested, second)
+ const original = source.getAttribute('position').array.slice()
+ const sourceDisposed = vi.spyOn(source, 'dispose')
+ const materialDisposed = vi.spyOn(material, 'dispose')
+
+ const geometries = stockMeshGeometries(scene)
+ const centers = geometries.map((geometry) => {
+ geometry.computeBoundingBox()
+ return geometry.boundingBox!.getCenter(new Vector3()).toArray()
+ })
+ expect(centers).toEqual([
+ [5, 27, 9],
+ [5, 7, 59],
+ ])
+ expect(geometries[0]!.boundingBox!.getSize(new Vector3()).toArray()).toEqual([20, 10, 30])
+ geometries.forEach((geometry) => geometry.dispose())
+ expect(source.getAttribute('position').array).toEqual(original)
+ expect(sourceDisposed).not.toHaveBeenCalled()
+ expect(materialDisposed).not.toHaveBeenCalled()
+ source.dispose()
+ material.dispose()
+ })
+
+ it('rejects a GLB scene without stock geometry', () => {
+ expect(() => stockMeshGeometries(new Group())).toThrow('The stock GLB contains no mesh.')
+ })
+
+ it('rejects unreadable GLB bytes', async () => {
+ await expect(parseStockGlb(new ArrayBuffer(12))).rejects.toThrow()
+ })
+})
diff --git a/packages/viewer/tests/stock-render.test.tsx b/packages/viewer/tests/stock-render.test.tsx
new file mode 100644
index 0000000..10d4d8e
--- /dev/null
+++ b/packages/viewer/tests/stock-render.test.tsx
@@ -0,0 +1,30 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { BoxGeometry } from 'three'
+import { describe, expect, it } from 'vitest'
+import { Stock } from '../src/stock.js'
+
+describe('Stock shape inputs', () => {
+ // The figure is built while Stock renders, before anything needs a Canvas.
+ const part = () => new BoxGeometry(20, 30, 10)
+
+ it('rejects a non-finite allowance with RangeError, as boxStockBounds does', () => {
+ for (const allowance of [NaN, Infinity, -Infinity]) {
+ expect(() =>
+ renderToStaticMarkup( ),
+ ).toThrow(RangeError)
+ }
+ })
+
+ it('rejects non-finite figures with RangeError', () => {
+ expect(() =>
+ renderToStaticMarkup(
+ ,
+ ),
+ ).toThrow(RangeError)
+ expect(() =>
+ renderToStaticMarkup(
+ ,
+ ),
+ ).toThrow(RangeError)
+ })
+})
diff --git a/packages/viewer/tests/stock-source.test.ts b/packages/viewer/tests/stock-source.test.ts
new file mode 100644
index 0000000..64bf04b
--- /dev/null
+++ b/packages/viewer/tests/stock-source.test.ts
@@ -0,0 +1,76 @@
+import { BufferGeometry } from 'three'
+import { expectTypeOf, it } from 'vitest'
+import type { StockProps } from '../src/stock.js'
+
+it('accepts exactly one geometry, GLB, box or cylinder source, with shared appearance props', () => {
+ const box = { lower: { x: 0, y: 0, z: 0 }, upper: { x: 10, y: 20, z: 30 } }
+ const cylinder = {
+ origin: { x: 0, y: 0, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ diameter: 10,
+ length: 20,
+ }
+ expectTypeOf({ geometry: new BufferGeometry(), opacity: 0.3 }).toExtend()
+ expectTypeOf({ glb: new ArrayBuffer(0), showEdges: false }).toExtend()
+ expectTypeOf({ glb: new ArrayBuffer(0), retainPrevious: true }).toExtend()
+ expectTypeOf({
+ glb: new ArrayBuffer(0),
+ renderStyle: 'legacy-workpiece' as const,
+ }).toExtend()
+ expectTypeOf({
+ glb: new ArrayBuffer(0),
+ emissive: 0x3c4051,
+ flatShading: true,
+ }).toExtend()
+ expectTypeOf({ box, color: 0xff0000 }).toExtend()
+ expectTypeOf({ cylinder, edgeOpacity: 0.5 }).toExtend()
+ expectTypeOf({
+ box: {
+ partGeometry: new BufferGeometry(),
+ dimensions: { x: 10, y: 20, z: 30 },
+ position: 'offset_from_top' as const,
+ positionOffset: 2,
+ },
+ }).toExtend()
+ expectTypeOf({
+ box: { partGeometry: new BufferGeometry(), allowance: { wall: 2, floor: 3 } },
+ }).toExtend()
+ expectTypeOf({
+ cylinder: { partGeometry: new BufferGeometry(), diameter: 10, length: 20 },
+ }).toExtend()
+ // @ts-expect-error Resolved box placement and preview options cannot be mixed.
+ expectTypeOf({ box: { ...box, partGeometry: new BufferGeometry() } }).toExtend()
+ // @ts-expect-error A resolved box is already placed; an offset would be ignored.
+ expectTypeOf({ box: { ...box, offset: { x: 5, y: 0, z: 0 } } }).toExtend()
+ // @ts-expect-error A resolved box is already sized; an allowance would be ignored.
+ expectTypeOf({ box: { ...box, allowance: 3 } }).toExtend()
+ // @ts-expect-error A resolved box is already sized; dimensions would be ignored.
+ expectTypeOf({ box: { ...box, dimensions: { x: 1, y: 1, z: 1 } } }).toExtend()
+ // @ts-expect-error A resolved box is already placed; a position would be ignored.
+ expectTypeOf({ box: { ...box, position: 'offset_from_top' as const } }).toExtend()
+ const mixedCylinder = { cylinder: { ...cylinder, partGeometry: new BufferGeometry() } }
+ const mixedBoxSizing = {
+ box: { partGeometry: new BufferGeometry(), dimensions: { x: 10, y: 20, z: 30 }, allowance: 2 },
+ }
+ const incompleteCylinder = { cylinder: { partGeometry: new BufferGeometry(), diameter: 10 } }
+ // @ts-expect-error Resolved cylinder placement and preview options cannot be mixed.
+ expectTypeOf(mixedCylinder).toExtend()
+ // @ts-expect-error Fixed box dimensions and allowance sizing are alternative preview modes.
+ expectTypeOf(mixedBoxSizing).toExtend()
+ // @ts-expect-error A cylinder preview requires diameter and length.
+ expectTypeOf(incompleteCylinder).toExtend()
+ // @ts-expect-error Exactly one source is required.
+ expectTypeOf({ geometry: new BufferGeometry(), glb: new ArrayBuffer(0) }).toExtend()
+ // @ts-expect-error A source cannot be omitted.
+ expectTypeOf({ opacity: 0.3 }).toExtend()
+ // @ts-expect-error Shape sources are also mutually exclusive.
+ expectTypeOf({ box, cylinder }).toExtend()
+ // @ts-expect-error Geometry and box cannot be supplied together.
+ expectTypeOf({ geometry: new BufferGeometry(), box }).toExtend()
+ // @ts-expect-error Geometry and cylinder cannot be supplied together.
+ expectTypeOf({ geometry: new BufferGeometry(), cylinder }).toExtend()
+ // @ts-expect-error GLB and box cannot be supplied together.
+ expectTypeOf({ glb: new ArrayBuffer(0), box }).toExtend()
+ // @ts-expect-error GLB and cylinder cannot be supplied together.
+ expectTypeOf({ glb: new ArrayBuffer(0), cylinder }).toExtend()
+})
diff --git a/packages/viewer/tests/stock.test.ts b/packages/viewer/tests/stock.test.ts
index debfa9b..da4f09b 100644
--- a/packages/viewer/tests/stock.test.ts
+++ b/packages/viewer/tests/stock.test.ts
@@ -2,18 +2,76 @@ import {
Box3,
BoxGeometry,
BufferGeometry,
+ EdgesGeometry,
Float32BufferAttribute,
Group,
+ LineSegments,
Mesh,
Raycaster,
Vector3,
} from 'three'
import { describe, expect, it, vi } from 'vitest'
+import type { Vec3 } from '../src/model/types.js'
import { contentBounds, partBounds } from '../src/render/camera.js'
import { hitUnderRay } from '../src/render/section.js'
-import { boxStockBounds, createStock, fixedBoxStockBounds } from '../src/render/stock.js'
+import {
+ boxStockBounds,
+ boxStockGeometry,
+ createStock,
+ cylinderStockGeometry,
+ fixedBoxStockBounds,
+ orientedBoxStockGeometry,
+ type StockPosition,
+} from '../src/render/stock.js'
+
+const bounds = (geometry: BufferGeometry) => {
+ geometry.computeBoundingBox()
+ return geometry.boundingBox!
+}
+
+/**
+ * Where every vertex of a cylinder mesh sits relative to the figure: how far
+ * along the axis from the base, and how far out from the axis.
+ */
+function cylinderSpan(geometry: BufferGeometry, origin: Vec3, axis: Vec3) {
+ const base = new Vector3(origin.x, origin.y, origin.z)
+ const direction = new Vector3(axis.x, axis.y, axis.z).normalize()
+ const position = geometry.getAttribute('position')
+ const point = new Vector3()
+ let along = { min: Infinity, max: -Infinity }
+ let radius = 0
+ for (let index = 0; index < position.count; index += 1) {
+ point.fromBufferAttribute(position, index).sub(base)
+ const t = point.dot(direction)
+ along = { min: Math.min(along.min, t), max: Math.max(along.max, t) }
+ radius = Math.max(radius, point.addScaledVector(direction, -t).length())
+ }
+ return { along, radius }
+}
describe('stock dimensions', () => {
+ it('renders allowance and fixed-dimension preview options in part coordinates', () => {
+ const part = new BoxGeometry(20, 30, 10).translate(40, -10, 5)
+ const padded = bounds(
+ boxStockGeometry({
+ partGeometry: part,
+ allowance: { wall: 2, floor: 3 },
+ offset: { x: 1, y: 2, z: 3 },
+ }),
+ )
+ expect(padded.min.toArray()).toEqual([29, -25, 0])
+ expect(padded.max.toArray()).toEqual([53, 9, 16])
+ const fixed = bounds(
+ boxStockGeometry({
+ partGeometry: part,
+ dimensions: { x: 40, y: 50, z: 20 },
+ position: 'offset_from_top',
+ positionOffset: 2,
+ }),
+ )
+ expect(fixed.min.toArray()).toEqual([20, -35, -8])
+ expect(fixed.max.toArray()).toEqual([60, 15, 12])
+ })
it('adds allowance on both sides and offsets from an off-origin part centre', () => {
const geometry = new BoxGeometry(20, 30, 10).translate(40, -10, 5)
const positions = geometry.getAttribute('position').array.slice()
@@ -76,6 +134,216 @@ describe('stock dimensions', () => {
})
})
+describe('fixed cylinder stock', () => {
+ const part = () => new BoxGeometry(20, 30, 10).translate(40, -10, 5)
+
+ it('stands on the part Z and centres on its bounding box', () => {
+ const box = bounds(cylinderStockGeometry({ partGeometry: part(), diameter: 50, length: 20 }))
+ expect(box.min.toArray()).toEqual([15, -35, -5])
+ expect(box.max.toArray()).toEqual([65, 15, 15])
+ })
+
+ it('places its ends by the same rule as a fixed box', () => {
+ const cases: [StockPosition, number, number][] = [
+ ['model_centered', 3, -5],
+ ['offset_from_top', 2, -8],
+ ['offset_from_bottom', 2, -2],
+ ['offset_from_top', -1, -11],
+ ]
+ for (const [position, offset, bottom] of cases) {
+ const cylinder = bounds(
+ cylinderStockGeometry({
+ partGeometry: part(),
+ diameter: 50,
+ length: 20,
+ position,
+ positionOffset: offset,
+ }),
+ )
+ const box = fixedBoxStockBounds(part(), { x: 50, y: 50, z: 20 }, position, offset)
+ expect(cylinder.min.z).toBe(bottom)
+ expect(cylinder.min.z).toBe(box.min.z)
+ expect(cylinder.max.z).toBe(box.max.z)
+ }
+ })
+
+ it('rejects sizes and offsets that would build NaN geometry', () => {
+ for (const value of [0, -1, NaN, Infinity]) {
+ expect(() =>
+ cylinderStockGeometry({ partGeometry: part(), diameter: value, length: 20 }),
+ ).toThrow(RangeError)
+ expect(() =>
+ cylinderStockGeometry({ partGeometry: part(), diameter: 20, length: value }),
+ ).toThrow(RangeError)
+ }
+ expect(() =>
+ cylinderStockGeometry({
+ partGeometry: part(),
+ diameter: 20,
+ length: 20,
+ position: 'offset_from_top',
+ positionOffset: NaN,
+ }),
+ ).toThrow(RangeError)
+ expect(() =>
+ cylinderStockGeometry({ partGeometry: new BufferGeometry(), diameter: 20, length: 20 }),
+ ).toThrow(RangeError)
+ })
+})
+
+describe('cylinder stock geometry', () => {
+ it('runs from the base centre along the axis, whichever way the axis points', () => {
+ const origin = { x: 4, y: -7, z: 2 }
+ const axes: Vec3[] = [
+ { x: 0, y: 0, z: 1 },
+ { x: 1, y: 0, z: 0 },
+ { x: 0, y: -1, z: 0 },
+ { x: 0, y: 0, z: -1 },
+ { x: 2, y: 2, z: 1 },
+ ]
+ for (const axis of axes) {
+ const geometry = cylinderStockGeometry({ origin, axis, diameter: 30, length: 80 })
+ const { along, radius } = cylinderSpan(geometry, origin, axis)
+ // Positions are float32, so a micron is as close as they can agree.
+ expect(along.min).toBeCloseTo(0, 4)
+ expect(along.max).toBeCloseTo(80, 4)
+ expect(radius).toBeCloseTo(15, 4)
+ }
+ })
+
+ it('lies along X as round stock lying down does', () => {
+ const box = bounds(
+ cylinderStockGeometry({
+ origin: { x: -5, y: 1, z: 2 },
+ axis: { x: 1, y: 0, z: 0 },
+ diameter: 20,
+ length: 60,
+ }),
+ )
+ expect(box.min.x).toBeCloseTo(-5, 9)
+ expect(box.max.x).toBeCloseTo(55, 9)
+ expect(box.min.y).toBeCloseTo(-9, 9)
+ expect(box.max.y).toBeCloseTo(11, 9)
+ expect(box.min.z).toBeCloseTo(-8, 9)
+ expect(box.max.z).toBeCloseTo(12, 9)
+ })
+
+ it('outlines only its two rims', () => {
+ const geometry = cylinderStockGeometry({
+ origin: { x: 0, y: 0, z: 0 },
+ axis: { x: 1, y: 1, z: 0 },
+ diameter: 300,
+ length: 40,
+ })
+ // Two vertices per edge, one edge per side on each of the two rims.
+ expect(new EdgesGeometry(geometry, 15).getAttribute('position').count).toBe(2 * 2 * 64)
+ })
+
+ it('rejects figures that would build NaN geometry', () => {
+ const figure = {
+ origin: { x: 0, y: 0, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ diameter: 10,
+ length: 10,
+ }
+ expect(() => cylinderStockGeometry({ ...figure, axis: { x: 0, y: 0, z: 0 } })).toThrow(
+ RangeError,
+ )
+ expect(() => cylinderStockGeometry({ ...figure, axis: { x: NaN, y: 0, z: 1 } })).toThrow(
+ RangeError,
+ )
+ expect(() => cylinderStockGeometry({ ...figure, origin: { x: Infinity, y: 0, z: 0 } })).toThrow(
+ RangeError,
+ )
+ expect(() => cylinderStockGeometry({ ...figure, diameter: 0 })).toThrow(RangeError)
+ expect(() => cylinderStockGeometry({ ...figure, length: -1 })).toThrow(RangeError)
+ })
+})
+
+describe('oriented box stock geometry', () => {
+ it('uses part-coordinate corners when no frame is supplied', () => {
+ const box = bounds(
+ orientedBoxStockGeometry({
+ lower: { x: -5, y: 10, z: 20 },
+ upper: { x: 15, y: 40, z: 60 },
+ }),
+ )
+ expect(box.min.toArray()).toEqual([-5, 10, 20])
+ expect(box.max.toArray()).toEqual([15, 40, 60])
+ })
+ const frame = {
+ location: { x: 5, y: 5, z: 5 },
+ axis: { x: 0, y: 0, z: 1 },
+ refDirection: { x: 1, y: 0, z: 0 },
+ }
+
+ it('places the corners in an unrotated frame at its location', () => {
+ const box = bounds(
+ orientedBoxStockGeometry({
+ frame,
+ lower: { x: -1, y: -2, z: -3 },
+ upper: { x: 10, y: 20, z: 30 },
+ }),
+ )
+ expect(box.min.toArray()).toEqual([4, 3, 2])
+ expect(box.max.toArray()).toEqual([15, 25, 35])
+ })
+
+ it('reads corners along refDirection, axis × refDirection and axis', () => {
+ const box = bounds(
+ orientedBoxStockGeometry({
+ // x runs along part Y, y along part Z (axis × refDirection), z along part X.
+ frame: { ...frame, axis: { x: 1, y: 0, z: 0 }, refDirection: { x: 0, y: 1, z: 0 } },
+ lower: { x: 0, y: 0, z: 0 },
+ upper: { x: 10, y: 20, z: 30 },
+ }),
+ )
+ expect(box.min.toArray()).toEqual([5, 5, 5])
+ expect(box.max.toArray()).toEqual([35, 15, 25])
+ })
+
+ it('reads the frame as directions, so a non-unit or skewed frame neither scales nor shears', () => {
+ const box = bounds(
+ orientedBoxStockGeometry({
+ // A long axis, and a long reference direction tilted toward it.
+ frame: { ...frame, axis: { x: 0, y: 0, z: 3 }, refDirection: { x: 2, y: 0, z: 0.5 } },
+ lower: { x: -1, y: -2, z: -3 },
+ upper: { x: 10, y: 20, z: 30 },
+ }),
+ )
+ expect(box.min.toArray()).toEqual([4, 3, 2])
+ expect(box.max.toArray()).toEqual([15, 25, 35])
+ })
+
+ it('rejects degenerate frames and inverted corners', () => {
+ const lower = { x: 0, y: 0, z: 0 }
+ const upper = { x: 1, y: 1, z: 1 }
+ expect(() =>
+ orientedBoxStockGeometry({
+ frame: { ...frame, refDirection: { x: 0, y: 0, z: 2 } },
+ lower,
+ upper,
+ }),
+ ).toThrow(RangeError)
+ expect(() =>
+ orientedBoxStockGeometry({ frame: { ...frame, axis: { x: 0, y: 0, z: 0 } }, lower, upper }),
+ ).toThrow(RangeError)
+ expect(() =>
+ orientedBoxStockGeometry({
+ frame: { ...frame, refDirection: { x: 0, y: 0, z: 0 } },
+ lower,
+ upper,
+ }),
+ ).toThrow(RangeError)
+ expect(() =>
+ orientedBoxStockGeometry({ frame, lower: upper, upper: { x: 2, y: 1, z: 2 } }),
+ ).toThrow(RangeError)
+ expect(() => orientedBoxStockGeometry({ frame, lower, upper: { x: 1, y: NaN, z: 1 } })).toThrow(
+ RangeError,
+ )
+ })
+})
+
describe('stock scene integration', () => {
it('frames stock without changing part-relative bounds or intercepting tool rays', () => {
const root = new Group()
@@ -96,11 +364,54 @@ describe('stock scene integration', () => {
stock.dispose()
})
+ it('builds its outline once, and only when it is first shown', () => {
+ const stock = createStock(new BoxGeometry(10, 10, 10))
+ stock.showEdges(false)
+ expect(stock.object.children).toHaveLength(1)
+ stock.showEdges(true)
+ const [, edges] = stock.object.children
+ expect(edges).toBeInstanceOf(LineSegments)
+ stock.showEdges(false)
+ expect(edges!.visible).toBe(false)
+ stock.showEdges(true)
+ expect(stock.object.children).toEqual([stock.object.children[0], edges])
+ expect(edges!.visible).toBe(true)
+ stock.dispose()
+ })
+
+ it('matches legacy surface passes and edge ordering without copying the source geometry', () => {
+ const geometry = new BoxGeometry(10, 20, 30)
+ const stock = createStock(geometry, true)
+ stock.showEdges(true)
+ const surfaces = stock.object.children.filter((child) => child instanceof Mesh)
+ expect(surfaces).toHaveLength(2)
+ for (const surface of surfaces) {
+ expect((surface as Mesh).geometry).toBe(geometry)
+ expect((surface as Mesh).material).toBe(stock.material)
+ expect(surface.renderOrder).toBe(2)
+ }
+ const edges = stock.object.children.find((child) => child instanceof LineSegments)!
+ expect(edges.renderOrder).toBe(1)
+ expect(stock.edgeMaterial.depthWrite).toBe(true)
+ expect(stock.material.depthTest).toBe(true)
+ const root = new Group()
+ const part = new Mesh(new BoxGeometry(1, 1, 1))
+ root.add(stock.object, part)
+ const ray = new Raycaster(new Vector3(0, 0, 100), new Vector3(0, 0, -1))
+ expect(ray.intersectObject(root, true)[0]?.object).toBe(part)
+ const dispose = vi.spyOn(geometry, 'dispose')
+ stock.dispose()
+ expect(dispose).not.toHaveBeenCalled()
+ geometry.dispose()
+ })
+
it('disposes owned GPU resources while leaving caller geometry reusable', () => {
const geometry = new BoxGeometry(10, 10, 10)
const stock = createStock(geometry)
+ stock.showEdges(true)
const inputDisposed = vi.spyOn(geometry, 'dispose')
- const edgesDisposed = vi.spyOn(stock.edges.geometry, 'dispose')
+ const edges = stock.object.children[1] as LineSegments
+ const edgesDisposed = vi.spyOn(edges.geometry, 'dispose')
const materialDisposed = vi.spyOn(stock.material, 'dispose')
const edgeMaterialDisposed = vi.spyOn(stock.edgeMaterial, 'dispose')
stock.dispose()