Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/cylinder-stock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@toolpath/viewer': major
---

Consolidate stock rendering into `Stock`, accepting exactly one of `geometry`, `glb`, `box`, or `cylinder`. Box and cylinder inputs accept either resolved placement or part-relative preview options, with placement calculated internally. Resolved box figures support optional setup frames; resolved cylinder figures support arbitrary axes. Fetching, caching and artifact storage remain caller-owned. Stock outlines are built only when shown.

Remove `BoxStock` and `BoxStockProps`. Replace `<BoxStock partGeometry={geometry} allowance={allowance} />` with `<Stock box={{ partGeometry: geometry, allowance }} />`; for explicit dimensions use `<Stock box={{ partGeometry: geometry, dimensions, position, positionOffset, offset }} />`. Preview builders are internal, and inline options do not cause repeated part scans on ordinary renders. Existing `<Stock geometry={geometry} />` usage is unchanged.

`StockProps` is now a union type alias rather than an interface, so `interface X extends StockProps` no longer compiles; write `type X = StockProps & { … }` instead. Use `StockAppearanceProps` for the shared appearance props alone.
5 changes: 5 additions & 0 deletions .changeset/stock-mesh-transitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@toolpath/viewer': minor
---

Add optional `Stock` `retainPrevious` behavior to keep the current GLB visible until its replacement finishes decoding. Dispose replaced geometry after the swap, and discard cancelled decode results.
5 changes: 5 additions & 0 deletions .changeset/stock-workpiece-shading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@toolpath/viewer': minor
---

Add a `legacy-workpiece` render style to `Stock`, reproducing legacy white/emissive shading, 20% opacity, two surface passes and edges drawn before surfaces. Add optional `emissive` and `flatShading` appearance props. Existing overlay defaults and source geometry remain unchanged.
241 changes: 176 additions & 65 deletions examples/react-viewer/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as THREE from 'three'
import {
Axes,
Banana,
BoxStock,
Stock,
fixedBoxStockBounds,
directionHighlights,
directionLabel,
Expand Down Expand Up @@ -36,6 +36,7 @@ import {
} from '@toolpath/viewer'
import { AnalysisOptions } from './analysis-options'
import { MODELS, modelFromQuery } from './models'
import stockMeshFixture from '../../../packages/viewer/fixtures/mesh/stock-box.json'
import './style.css'

/**
Expand All @@ -55,6 +56,14 @@ const params = new URLSearchParams(window.location.search)
const projection: Projection =
params.get('projection') === 'orthographic' ? 'orthographic' : 'perspective'
const showOrbitTarget = params.get('orbitTarget') === 'on'
const STOCK_GLB_BYTES = Uint8Array.from(atob(stockMeshFixture.base64), (char) =>
char.charCodeAt(0),
).buffer
const LARGE_STOCK_GLB_BYTES = Uint8Array.from(atob(stockMeshFixture.largeBase64), (char) =>
char.charCodeAt(0),
).buffer
type StockShape = 'box' | 'cylinder'
type StockSource = 'geometry' | 'glb'
const requestedControls = params.get('controls')
const initialControls: ControlScheme = CONTROL_SCHEME_OPTIONS.some(
(option) => option.value === requestedControls,
Expand Down Expand Up @@ -215,7 +224,15 @@ const App = () => {
const [showDirections, setShowDirections] = useState(false)
const [focus, setFocus] = useState(false)
const [wireframe, setWireframe] = useState(false)
const [stockShape, setStockShape] = useState<StockShape>(
params.get('stockShape') === 'cylinder' ? 'cylinder' : 'box',
)
const [stockSource, setStockSource] = useState<StockSource>(
params.get('stockSource') === 'glb' ? 'glb' : 'geometry',
)
const [stockDimensions, setStockDimensions] = useState({ x: 25.908, y: 25.908, z: 25.908 })
const [stockCylinder, setStockCylinder] = useState({ diameter: 38.1, length: 25.908 })
const [stockMeshSize, setStockMeshSize] = useState('40')
const [stockPosition, setStockPosition] = useState<StockPosition>('model_centered')
const [stockPositionOffset, setStockPositionOffset] = useState(0)
const stockSize = useMemo(
Expand Down Expand Up @@ -346,69 +363,146 @@ const App = () => {
<p>{part.hint}</p>
<p>
<strong>Stock:</strong>{' '}
{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)`}
</p>
<label className="stock-allowance">
Stock X dimension (mm)
<input
type="number"
min="0"
max="100"
step="0.5"
value={stockDimensions.x}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockDimensions((current) => ({ ...current, x: value }))
}}
/>
</label>
<label className="stock-allowance">
Stock Y dimension (mm)
<input
type="number"
min="0"
max="100"
step="0.5"
value={stockDimensions.y}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockDimensions((current) => ({ ...current, y: value }))
}}
/>
</label>
<label className="stock-allowance">
Stock Z dimension (mm)
<input
type="number"
min="0.01"
max="1000"
step="0.01"
value={stockDimensions.z}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockDimensions((current) => ({ ...current, z: value }))
}}
/>
</label>
<label className="stock-allowance">
Stock position
Stock source
<select
value={stockPosition}
onChange={(event) => setStockPosition(event.target.value as StockPosition)}
value={stockSource}
onChange={(event) => setStockSource(event.target.value as StockSource)}
>
<option value="model_centered">Model centered</option>
<option value="offset_from_top">Offset from top</option>
<option value="offset_from_bottom">Offset from bottom</option>
<option value="geometry">Shape preview</option>
<option value="glb">GLB fixture</option>
</select>
</label>
{stockPosition !== 'model_centered' ? (
{stockSource === 'geometry' ? (
<label className="stock-allowance">
Stock shape
<select
value={stockShape}
onChange={(event) => setStockShape(event.target.value as StockShape)}
>
<option value="box">Box</option>
<option value="cylinder">Cylinder</option>
</select>
</label>
) : null}
{stockSource === 'glb' ? (
<label className="stock-allowance">
GLB blank
<select
value={stockMeshSize}
onChange={(event) => setStockMeshSize(event.target.value)}
>
<option value="40">40 mm box</option>
<option value="60">60 mm box</option>
</select>
</label>
) : null}
{stockSource === 'geometry' && stockShape === 'cylinder' ? (
<>
<label className="stock-allowance">
Stock diameter (mm)
<input
type="number"
min="0.01"
max="1000"
step="0.01"
value={stockCylinder.diameter}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockCylinder((current) => ({ ...current, diameter: value }))
}}
/>
</label>
<label className="stock-allowance">
Stock length (mm)
<input
type="number"
min="0.01"
max="1000"
step="0.01"
value={stockCylinder.length}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockCylinder((current) => ({ ...current, length: value }))
}}
/>
</label>
</>
) : null}
{stockSource === 'geometry' && stockShape === 'box' ? (
<>
<label className="stock-allowance">
Stock X dimension (mm)
<input
type="number"
min="0"
max="100"
step="0.5"
value={stockDimensions.x}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockDimensions((current) => ({ ...current, x: value }))
}}
/>
</label>
<label className="stock-allowance">
Stock Y dimension (mm)
<input
type="number"
min="0"
max="100"
step="0.5"
value={stockDimensions.y}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockDimensions((current) => ({ ...current, y: value }))
}}
/>
</label>
<label className="stock-allowance">
Stock Z dimension (mm)
<input
type="number"
min="0.01"
max="1000"
step="0.01"
value={stockDimensions.z}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && value > 0 && value <= 1000)
setStockDimensions((current) => ({ ...current, z: value }))
}}
/>
</label>
</>
) : null}
{stockSource === 'geometry' ? (
<label className="stock-allowance">
Stock position
<select
value={stockPosition}
onChange={(event) => setStockPosition(event.target.value as StockPosition)}
>
<option value="model_centered">Model centered</option>
<option value="offset_from_top">Offset from top</option>
<option value="offset_from_bottom">Offset from bottom</option>
</select>
</label>
) : null}
{stockSource === 'geometry' && stockPosition !== 'model_centered' ? (
<label className="stock-allowance">
Position offset (mm)
<input
Expand All @@ -426,7 +520,9 @@ const App = () => {
</label>
) : null}
<p className="small-note">
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.'}
</p>
<button
className="detail-button"
Expand Down Expand Up @@ -605,12 +701,27 @@ const App = () => {
}}
onPick={(pick: PartPick) => setSelected([...pick.ranked])}
/>
{showStock ? (
<BoxStock
partGeometry={part.geometry}
dimensions={stockDimensions}
position={stockPosition}
positionOffset={stockPositionOffset}
{showStock && stockSource === 'geometry' && stockShape === 'cylinder' ? (
<Stock
cylinder={{
partGeometry: part.geometry,
...stockCylinder,
position: stockPosition,
positionOffset: stockPositionOffset,
}}
/>
) : null}
{showStock && stockSource === 'glb' ? (
<Stock glb={stockMeshSize === '40' ? STOCK_GLB_BYTES : LARGE_STOCK_GLB_BYTES} />
) : null}
{showStock && stockSource === 'geometry' && stockShape === 'box' ? (
<Stock
box={{
partGeometry: part.geometry,
dimensions: stockDimensions,
position: stockPosition,
positionOffset: stockPositionOffset,
}}
/>
) : null}
<DirectionArrows
Expand Down
62 changes: 62 additions & 0 deletions examples/react-viewer/tests/toolbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,68 @@ for (const projection of ['perspective', 'orthographic'] as const) {
await expect.poll(async () => 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,
}) => {
Expand Down
Loading
Loading