Skip to content
Open
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
6 changes: 5 additions & 1 deletion plugins/mesh-tools/app/meshCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const regionSchema = (what: string): JsonSchema =>
{
box: obj({ min: vec3('The low corner, mm.'), max: vec3('The high corner, mm.') }, ['min', 'max'], 'The vertices inside an axis-aligned box, its sides included.'),
sphere: obj({ center: vec3('The centre, mm.'), radius: num('mm.', { exclusiveMinimum: 0 }) }, ['center', 'radius'], 'The vertices within a radius of a point.'),
vertices: arr(int(undefined, { minimum: 0 }), 'The vertices by number — the edited copy’s while there is one, else the scan’s.', { minItems: 1 }),
vertices: arr(int(undefined, { minimum: 0, maximum: 0xffffffff }), 'The vertices by number — the edited copy’s while there is one, else the scan’s.', { minItems: 1 }),
facing: vec3('Of those, only the vertices whose normal points within angleDeg of this direction — [0, 0, 1] for the faces looking up. Narrows a box to one face of the part.'),
angleDeg: num(`How far a normal may turn from facing and still face it, degrees — ${DEFAULT_FACING_DEG} to start.`, { exclusiveMinimum: 0, maximum: 180 }),
},
Expand All @@ -153,6 +153,10 @@ async function regionVertices(region: RegionIn): Promise<Uint32Array> {
}
const shapes = (['box', 'sphere', 'vertices'] as const).filter((k) => region[k] !== undefined)
if (shapes.length !== 1) invalid('Name the region one way: a box, a sphere or vertices — with facing and angleDeg to narrow it.')
// A number past 32 bits would wrap round to another vertex when made a
// Uint32Array, out of reach of the worker's own check that it is one.
const tooBig = region.vertices?.find((v) => !(v >= 0 && v <= 0xffffffff))
if (tooBig !== undefined) invalid(`The mesh has no vertex ${tooBig}.`)
let found: { vertices: Uint32Array; vertexCount: number }
try {
found = await client().select({
Expand Down
6 changes: 4 additions & 2 deletions plugins/mesh-tools/app/meshToolsSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { reduceSettingsOf, useMeshTools, type ScanEdit } from '../state/meshStor
import type { ImportQueue } from '../../../src/app/importQueue'
import { runImport } from '../../../src/app/imports'
import type { SourceFiles } from '../../../src/app/project'
import { editedCopy, type EditedCopy } from '../state/editedCopy'
import { editedCopy, editedCopyBytes, type EditedCopy } from '../state/editedCopy'
import type { ScanSource } from '../../../src/app/scanSwap'
import { onScan } from '../../../src/app/scanEvents'
import { meanEdgeLength } from '../core/smooth'
Expand Down Expand Up @@ -797,7 +797,9 @@ export function createMeshToolsSession({
// the copy from the other side of it in place — after the app has put
// the scan from that side in place, which takes the copy away.
registerHistory({
sections: [{ key: 'edited', compare: 'identity', capture: () => editedCopy.current ?? null, apply: () => {} }],
// Weighed, so the history keeps no more versions of a large copy
// than fit its byte budget.
sections: [{ key: 'edited', compare: 'identity', capture: () => editedCopy.current ?? null, apply: () => {}, bytes: (copy) => editedCopyBytes(copy as EditedCopy) }],
beforeRestore: async (patch) => {
if ('edited' in patch && (patch.edited ?? null) !== (editedCopy.current ?? null)) {
await setEditedCopy((patch.edited as EditedCopy | null) ?? null)
Expand Down
42 changes: 38 additions & 4 deletions plugins/mesh-tools/app/projectSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ function mapBytes(map: Int32Array): Uint8Array {
return bytes
}

/** The vertex count a copy's PLY header states, or -1 for a header that
* does not state one. The copy is always the binary PLY buildBinaryPly
* writes, so its header is short ASCII at the very start. */
function plyVertexCount(bytes: Uint8Array): number {
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 4096))
const end = head.indexOf('end_header')
const m = /^element vertex (\d+)$/m.exec(end < 0 ? '' : head.slice(0, end))
return m ? Number(m[1]) : -1
}

const BOOLEAN_OPS: readonly string[] = ['subtract', 'add', 'intersect']
const SIDES: readonly string[] = ['in', 'out', 'both']

Expand Down Expand Up @@ -307,6 +317,15 @@ export const meshSection: ProjectSection<MeshPart> = {
for (const member of part.copy ? [part.copy.member, part.copy.mapMember] : []) {
if (!members.has(member)) throw new Error(`Project is missing ${member}.`)
}
// The map is checked here, while the session being replaced is still
// whole, as far as it can be without the scan it belongs to: whole
// int32s, each a vertex number or -1 for one an edit took.
const map = part.copy && members.get(part.copy.mapMember)
if (map) {
if (map.byteLength % 4 !== 0) throw new Error('Malformed project: mesh.copy.')
const numbers = new Int32Array(map.slice().buffer)
for (let i = 0; i < numbers.length; i++) if (numbers[i] < -1) throw new Error('Malformed project: mesh.copy.')
}
const state = meshPartState(part)
return () => useMeshTools.setState(state)
},
Expand All @@ -318,8 +337,16 @@ export const meshSection: ProjectSection<MeshPart> = {
const bytes = members.get(copy.member)
const map = members.get(copy.mapMember)
if (!bytes || !map) throw new Error(`Project is missing ${bytes ? copy.mapMember : copy.member}.`)
// Only now is the scan in place to size the map against, and the copy's
// header to bound its numbers: one past the copy's vertices would carry
// a selection onto vertices the copy does not have.
const numbers = new Int32Array(map.slice().buffer)
if (map.byteLength % 4 !== 0 || numbers.length !== useStore.getState().vertexCount) {
const copyVertices = plyVertexCount(bytes)
let fits = numbers.length === useStore.getState().vertexCount && copyVertices >= 0
for (let i = 0; fits && i < numbers.length; i++) if (numbers[i] >= copyVertices) fits = false
if (!fits) {
// The edits listed as waiting on the copy went with it.
useMeshTools.setState({ pending: [] })
throw new Error('Malformed project: mesh.copy.')
}
await meshToolsSession()?.setEditedCopy({ source: { name: readAs(manifest.scan.fileName, copy.member), bytes }, map: numbers })
Expand All @@ -329,10 +356,17 @@ export const meshSection: ProjectSection<MeshPart> = {
if (manifest.scan && part.reduced) await meshToolsSession()?.runReduce(usePrefs.getState().units)
},
// The settings alone are no work; an edited scan is, the file it came from
// no longer saying it.
// no longer saying it, and so is a copy with edits not yet taken into use,
// which nothing but this session holds.
hasContent: () => false,
holdsWork: () => useMeshTools.getState().edits.length > 0,
holds: () => ({ scanEdits: useMeshTools.getState().edits.length }),
holdsWork: () => {
const s = useMeshTools.getState()
return s.edits.length > 0 || s.pending.length > 0
},
holds: () => {
const s = useMeshTools.getState()
return { scanEdits: s.edits.length, copyEdits: s.pending.length }
},
watch(changed) {
// The edits come with the scan's file changing under them — the file is
// not in any store, so the list of them is what is watched.
Expand Down
76 changes: 44 additions & 32 deletions plugins/mesh-tools/app/useMeshWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ import type { EditRun, MeshToolsSession } from './meshToolsSession'

const message = (e: unknown) => (e instanceof Error ? e.message : String(e))

/**
* Make a survey for the tool in hand: `ask` the worker, and hand what it
* found to `done` while `current` still says it is wanted. A failure is
* said, and kept as the survey's state — with only a toast, the panel would
* go on showing it as still being made, with no way to ask again.
*/
export function runSurvey<T>(ask: () => Promise<T>, current: () => boolean, done: (found: T) => void | Promise<void>): Promise<void> {
useMeshTools.getState().setSurveyFailed(null)
return ask()
.then((found) => {
if (current()) return done(found)
})
.catch((e: unknown) => {
if (!current()) return
useMeshTools.getState().setSurveyFailed(message(e))
useStore.getState().setError(message(e))
})
}

export function useMeshWorkspace(session: MeshToolsSession) {
const { clientRef, sceneRef, copyField, copyRgb, meshOfSolid, trimBodyOf } = session

Expand Down Expand Up @@ -70,6 +89,7 @@ export function useMeshWorkspace(session: MeshToolsSession) {
const triangleCount = useStore((s) => s.triangleCount)
const appliedAlignment = useStore((s) => s.appliedAlignment)
const copyVersion = useMeshTools((s) => s.copyVersion)
const surveyAttempt = useMeshTools((s) => s.surveyAttempt)
const surveying = workspace === 'mesh' && tool === 'islands' && fileName !== null && triangleCount > 0
const islands = useRef<{ triangles: Float32Array; centres: Float32Array } | null>(null)
useEffect(() => {
Expand All @@ -79,20 +99,18 @@ export function useMeshWorkspace(session: MeshToolsSession) {
const editedVersion = client.editedVersion
let stale = false
useMeshTools.getState().setSurvey(null)
client
.surveyIslands(pieceSize)
.then(({ survey, triangles, centres }) => {
if (stale || scanVersion !== client.scanVersion || editedVersion !== client.editedVersion) return
void runSurvey(
() => client.surveyIslands(pieceSize),
() => !stale && scanVersion === client.scanVersion && editedVersion === client.editedVersion,
({ survey, triangles, centres }) => {
islands.current = { triangles, centres }
useMeshTools.getState().setSurvey({ ...survey, minSize: pieceSize })
})
.catch((e: unknown) => {
if (!stale) useStore.getState().setError(e instanceof Error ? e.message : String(e))
})
},
)
return () => {
stale = true
}
}, [surveying, pieceSize, triangleCount, edits, copyVersion, appliedAlignment, clientRef])
}, [surveying, pieceSize, triangleCount, edits, copyVersion, appliedAlignment, surveyAttempt, clientRef])

const surveyVersion = useMeshTools((s) => s.surveyVersion)
useEffect(() => {
Expand Down Expand Up @@ -297,20 +315,18 @@ export function useMeshWorkspace(session: MeshToolsSession) {
const editedVersion = client.editedVersion
let stale = false
useMeshTools.getState().setHoleSurvey(null)
client
.surveyHoles(holeSize)
.then(({ survey, holes, centres, open }) => {
if (stale || scanVersion !== client.scanVersion || editedVersion !== client.editedVersion) return
void runSurvey(
() => client.surveyHoles(holeSize),
() => !stale && scanVersion === client.scanVersion && editedVersion === client.editedVersion,
({ survey, holes, centres, open }) => {
rims.current = { holes, centres, open }
useMeshTools.getState().setHoleSurvey({ ...survey, maxSize: holeSize })
})
.catch((e: unknown) => {
if (!stale) useStore.getState().setError(e instanceof Error ? e.message : String(e))
})
},
)
return () => {
stale = true
}
}, [sortingHoles, holeSize, triangleCount, edits, copyVersion, appliedAlignment, clientRef])
}, [sortingHoles, holeSize, triangleCount, edits, copyVersion, appliedAlignment, surveyAttempt, clientRef])

// What Shell will do with the mesh, looked at while it is in hand and again
// for each version of the mesh: hollow it, closed; thicken it, open; or,
Expand All @@ -324,24 +340,20 @@ export function useMeshWorkspace(session: MeshToolsSession) {
let stale = false
const current = () => !stale && scanVersion === client.scanVersion && editedVersion === client.editedVersion
useMeshTools.getState().setShellSurvey(null)
client
.closedness()
.then(async (c) => {
if (!current()) return
if (c.open === 0) {
useMeshTools.getState().setShellSurvey({ ...c, rims: 0, holes: 0, holeSize })
return
}
void runSurvey(
async () => {
const c = await client.closedness()
if (c.open === 0 || !current()) return { ...c, rims: 0, holes: 0 }
const { survey } = await client.surveyHoles(holeSize)
if (current()) useMeshTools.getState().setShellSurvey({ ...c, rims: survey.rims, holes: survey.holes, holeSize })
})
.catch((e: unknown) => {
if (!stale) useStore.getState().setError(e instanceof Error ? e.message : String(e))
})
return { ...c, rims: survey.rims, holes: survey.holes }
},
current,
(found) => useMeshTools.getState().setShellSurvey({ ...found, holeSize }),
)
return () => {
stale = true
}
}, [shelling, holeSize, triangleCount, edits, copyVersion, clientRef])
}, [shelling, holeSize, triangleCount, edits, copyVersion, surveyAttempt, clientRef])

const holeSurveyVersion = useMeshTools((s) => s.holeSurveyVersion)
useEffect(() => {
Expand Down
3 changes: 3 additions & 0 deletions plugins/mesh-tools/state/editedCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export interface EditedCopy {
map: Int32Array
}

/** The memory a version of the copy holds: its file and its numbering. */
export const editedCopyBytes = (copy: EditedCopy): number => copy.source.bytes.byteLength + copy.map.byteLength

/** The copy being edited, or null with none. Kept like the app keeps the
* bytes of every model it opened (app/project SourceFiles): out of every
* store, being a big buffer, and one to a session. */
Expand Down
18 changes: 17 additions & 1 deletion plugins/mesh-tools/state/meshStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ interface MeshState {
survey: (PieceCount & { minSize: number }) | null
/** Bumped when a survey lands, so the viewport marks its islands. */
surveyVersion: number
/** Why the survey of the tool in hand — its islands, holes or what Shell
* will do — failed, or null. Kept apart from a survey of null, which is
* one still being made, so a panel does not wait on it for ever. */
surveyFailed: string | null
/** Bumped by Retry, to make the failed survey again. */
surveyAttempt: number
/** The islands marked on the part — or the surface marked by hand, for
* Delete marked — have been asked to go, and wait on a yes. Nothing is
* deleted before it; any change to what is marked — another size,
Expand Down Expand Up @@ -385,6 +391,8 @@ interface MeshState {
setView: (view: MeshView) => void
setPieceSize: (v: number) => void
setSurvey: (survey: (PieceCount & { minSize: number }) | null) => void
setSurveyFailed: (why: string | null) => void
retrySurvey: () => void
setConfirming: (on: boolean) => void
setEditing: (on: boolean) => void
addEdit: (edit: ScanEdit) => void
Expand Down Expand Up @@ -454,6 +462,8 @@ const CLEARED = {
message: null,
report: null,
survey: null,
surveyFailed: null,
surveyAttempt: 0,
holeSurvey: null,
cutPreview: null,
boolClosed: null,
Expand Down Expand Up @@ -559,6 +569,10 @@ export const useMeshTools = create<MeshState>()((set) => ({

setSurvey: (survey) => set((s) => ({ survey, surveyVersion: s.surveyVersion + 1, confirming: false })),

setSurveyFailed: (surveyFailed) => set({ surveyFailed }),

retrySurvey: () => set((s) => ({ surveyFailed: null, surveyAttempt: s.surveyAttempt + 1 })),

setConfirming: (confirming) => set({ confirming }),

setEditing: (editing) => set({ editing }),
Expand Down Expand Up @@ -719,7 +733,9 @@ const count = (n: number) => n.toLocaleString('en-US')
* named again. */
function cutPlaneText(plane: string, units: DisplayUnits): string {
if (units === 'mm') return plane
return plane.replace(/(\d+(?:\.\d+)?) mm$/, (_, v: string) => typedLen(Number(v), units))
// String() writes a very small or very large offset in exponent form
// ("1e-7"), and the whole figure has to be read, not just its exponent.
return plane.replace(/(\d+(?:\.\d+)?(?:e[+-]?\d+)?) mm$/, (_, v: string) => typedLen(Number(v), units))
}

/** One line of the list of edits, as the panel shows it — its lengths in
Expand Down
3 changes: 3 additions & 0 deletions plugins/mesh-tools/tests/meshCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ describe('cleaning a scan up through the commands', () => {
expect((await refused('mesh.delete_marked', { region: { box: { min: [50, 50, 50], max: [60, 60, 60] } } })).code).toBe('invalid_input')
expect((await refused('mesh.delete_marked', { region: { box: { min: [0, 0, 0], max: [1, 1, 1] }, sphere: { center: [0, 0, 0], radius: 1 } } })).code).toBe('invalid_input')
expect((await refused('mesh.delete_marked', { region: 'marked' })).code).toBe('unavailable')
// A vertex number past 32 bits is refused, not wrapped onto vertex 1.
expect((await refused('mesh.delete_marked', { region: { vertices: [2 ** 32 + 1] } })).code).toBe('invalid_input')
expect(undoLabels()).toEqual([])
expect((await refused('mesh.mark', { region: { sphere: { center: [0, 0, 10], radius: 3 } } })).code).toBe('unavailable')
expect((await refused('mesh.boolean', { op: 'subtract', solids: ['Peg'] })).code).toBe('not_found')
expect((await refused('mesh.cut', { plane: 'Plane 9', offset: 0, keep: 'plus' })).code).toBe('not_found')
Expand Down
45 changes: 45 additions & 0 deletions plugins/mesh-tools/tests/meshHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Versions of the edited copy in the undo history: bounded by the memory
// they hold, not only by their count.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { clearHistory, configureHistory, HISTORY_BYTE_BUDGET, historyAction, registerHistory, useHistory } from '../../../src/state/historyStore'
import { editedCopy, editedCopyBytes, type EditedCopy } from '../state/editedCopy'

/** A copy that says it is `mb` MB, without holding it: the history only
* asks for its byteLength. */
function fakeCopy(mb: number): EditedCopy {
const bytes = { byteLength: mb * 1e6 } as Uint8Array
return { source: { name: 'scan.ply', bytes }, map: new Int32Array(0) }
}

let stops: (() => void)[] = []
beforeEach(() => {
clearHistory()
stops = [
configureHistory({}),
registerHistory({ sections: [{ key: 'edited', compare: 'identity', capture: () => editedCopy.current, apply: () => {}, bytes: (c) => editedCopyBytes(c as EditedCopy) }] }),
]
})
afterEach(() => {
stops.forEach((stop) => stop())
editedCopy.current = null
})

describe('the edited copy in the undo history', () => {
it('keeps no more versions of a large copy than fit the byte budget', () => {
for (let i = 0; i < 70; i++) historyAction(`edit ${i}`, () => (editedCopy.current = fakeCopy(105)))
const past = useHistory.getState().past
const held = new Set<EditedCopy>()
for (const e of past) for (const side of [e.before, e.after]) if (side.edited) held.add(side.edited as EditedCopy)
const bytes = [...held].reduce((sum, c) => sum + editedCopyBytes(c), 0)
expect(bytes).toBeLessThanOrEqual(HISTORY_BYTE_BUDGET)
expect(past.length).toBeLessThan(64)
// The newest steps are the ones kept.
expect(past.at(-1)!.label).toBe('edit 69')
})

it('keeps a step however large its copy', () => {
historyAction('edit', () => (editedCopy.current = fakeCopy(2000)))
expect(useHistory.getState().past).toHaveLength(1)
})
})
17 changes: 17 additions & 0 deletions plugins/mesh-tools/tests/meshPanels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
// The tool panels read the Mesh Tools store through a selector: one that
// takes the whole store renders again on every write to it.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'

const TOOLS = join(__dirname, '../ui/tools')

describe('the Mesh Tools panels', () => {
it('subscribe to the fields they read, not the whole store', () => {
const whole = readdirSync(TOOLS)
.filter((f) => f.endsWith('.tsx'))
.filter((f) => /useMeshTools\(\s*\)/.test(readFileSync(join(TOOLS, f), 'utf8')))
expect(whole).toEqual([])
})
})
Loading
Loading