feat: complete boolean task links and recompute closure
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -360,7 +360,16 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
|
||||
}
|
||||
const currentFeatureField = activeTask?.commandId ? featureFields[activeTask.commandId] : undefined
|
||||
const activeCommand = activeTask?.commandId
|
||||
return <div className="task-panel"><div className="task-actions-top"><button className="button button-primary" onClick={() => { facade.task.apply(); showNotice('Task accepted') }}>OK</button><button className="button button-outline" onClick={() => { facade.task.update({ preview: true }); showNotice('Preview applied') }}>Apply</button><button className="button button-quiet" onClick={() => { facade.task.cancel(); showNotice('Task cancelled') }}>Cancel</button></div><div className="task-header"><div className="task-icon"><Pencil size={16} /></div><div><span className="eyebrow">Active command</span><h2>{isSketch ? 'Edit Sketch' : definition.taskTitle}</h2></div><Badge tone="cyan">Preview</Badge></div><div className="task-body"><div className="task-step"><span className="step-index">1</span><div><strong>{isSketch ? 'Geometry and constraints' : definition.objectType}</strong><span>{definition.taskSummary}</span></div></div>{activeCommand === 'primitive' && <><label className="field-label">Primitive type<select className="field-input" value={primitiveType} onChange={(event) => facade.task.update({ primitiveType: event.target.value })}><option>Box</option><option>Cylinder</option><option>Sphere</option><option>Cone</option></select></label>{primitiveFields.map((field) => <label className="field-label" key={field}>{primitiveLabel(field)} <span className="field-unit">{primitiveUnits[field]}</span><input className="field-input" type="number" min={field === 'radius2' ? 0 : 0.001} max={primitiveUnits[field] === 'deg' ? 360 : undefined} step={primitiveUnits[field] === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[field] === 'number' ? Number(activeTask?.draft[field]) : primitiveFallbacks[field]} onChange={(event) => facade.task.update({ [field]: Number(event.target.value) })} /></label>)}</>}{currentFeatureField && <label className="field-label">{currentFeatureField.label} <span className="field-unit">{currentFeatureField.unit}</span><input className="field-input" type="number" min={0.001} max={currentFeatureField.unit === 'deg' ? 360 : undefined} step={currentFeatureField.unit === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[currentFeatureField.key] === 'number' ? Number(activeTask?.draft[currentFeatureField.key]) : currentFeatureField.fallback} onChange={(event) => facade.task.update({ [currentFeatureField.key]: Number(event.target.value) })} /></label>}{activeCommand === 'pocket' && <label className="field-label">Type<select className="field-input" value={typeof activeTask?.draft.type === 'string' ? activeTask.draft.type : 'Through all'} onChange={(event) => facade.task.update({ type: event.target.value })}><option>Dimension</option><option>Through all</option><option>Up to face</option></select></label>}{['pad', 'pocket', 'revolution'].includes(activeCommand || '') && <label className="check-row"><input type="checkbox" checked={activeTask?.draft.reversed === true} onChange={(event) => facade.task.update({ reversed: event.target.checked })} /><span>Reversed</span></label>}{activeCommand === 'pad' && <label className="check-row"><input type="checkbox" checked={activeTask?.draft.midplane === true} onChange={(event) => facade.task.update({ midplane: event.target.checked })} /><span>Symmetric to plane</span></label>}{!activeCommand || activeCommand === 'create-sketch' ? <><label className="field-label">Primary value <span className="field-unit">mm</span><input className="field-input" value={isSketch ? 'Fully constrained' : '42.00'} readOnly /></label><label className="field-label">Operation<select className="field-input"><option>{workbench === 'Part Design' ? 'Dimension' : 'Contextual preview'}</option><option>Through all</option><option>Up to face</option></select></label></> : null}<label className="check-row"><input type="checkbox" defaultChecked /><span>Preview result in viewport</span></label><div className="task-note"><AlertTriangle size={14} /><span>Changes remain local until the document is recomputed.</span></div></div></div>
|
||||
const activeDocument = facade.app.document.getActive()
|
||||
const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection'
|
||||
const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId))
|
||||
const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : ''
|
||||
const acceptTask = () => {
|
||||
if (!activeTask || activeTask.status !== 'preview') { showNotice('No active task'); return }
|
||||
facade.task.apply()
|
||||
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Task accepted and recompute completed' : `Task accepted; recompute ${result.status}`))
|
||||
}
|
||||
return <div className="task-panel"><div className="task-actions-top"><button className="button button-primary" onClick={acceptTask}>OK</button><button className="button button-outline" onClick={() => { facade.task.update({ preview: true }); showNotice('Preview applied') }}>Apply</button><button className="button button-quiet" onClick={() => { facade.task.cancel(); showNotice('Task cancelled') }}>Cancel</button></div><div className="task-header"><div className="task-icon"><Pencil size={16} /></div><div><span className="eyebrow">Active command</span><h2>{isSketch ? 'Edit Sketch' : definition.taskTitle}</h2></div><Badge tone="cyan">Preview</Badge></div><div className="task-body"><div className="task-step"><span className="step-index">1</span><div><strong>{isSketch ? 'Geometry and constraints' : definition.objectType}</strong><span>{definition.taskSummary}</span></div></div>{activeCommand === 'primitive' && <><label className="field-label">Primitive type<select className="field-input" value={primitiveType} onChange={(event) => facade.task.update({ primitiveType: event.target.value })}><option>Box</option><option>Cylinder</option><option>Sphere</option><option>Cone</option></select></label>{primitiveFields.map((field) => <label className="field-label" key={field}>{primitiveLabel(field)} <span className="field-unit">{primitiveUnits[field]}</span><input className="field-input" type="number" min={field === 'radius2' ? 0 : 0.001} max={primitiveUnits[field] === 'deg' ? 360 : undefined} step={primitiveUnits[field] === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[field] === 'number' ? Number(activeTask?.draft[field]) : primitiveFallbacks[field]} onChange={(event) => facade.task.update({ [field]: Number(event.target.value) })} /></label>)}</>}{booleanCommand && <><label className="field-label">Base<select className="field-input" value={draftLink('base')} onChange={(event) => facade.task.update({ base: event.target.value })}><option value="">Select base</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{object.properties.find((property) => property.name === 'Label')?.value || object.id}</option>)}</select></label><label className="field-label">Tool<select className="field-input" value={draftLink('tool')} onChange={(event) => facade.task.update({ tool: event.target.value })}><option value="">Select tool</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{object.properties.find((property) => property.name === 'Label')?.value || object.id}</option>)}</select></label></>}{currentFeatureField && <label className="field-label">{currentFeatureField.label} <span className="field-unit">{currentFeatureField.unit}</span><input className="field-input" type="number" min={0.001} max={currentFeatureField.unit === 'deg' ? 360 : undefined} step={currentFeatureField.unit === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[currentFeatureField.key] === 'number' ? Number(activeTask?.draft[currentFeatureField.key]) : currentFeatureField.fallback} onChange={(event) => facade.task.update({ [currentFeatureField.key]: Number(event.target.value) })} /></label>}{activeCommand === 'pocket' && <label className="field-label">Type<select className="field-input" value={typeof activeTask?.draft.type === 'string' ? activeTask.draft.type : 'Through all'} onChange={(event) => facade.task.update({ type: event.target.value })}><option>Dimension</option><option>Through all</option><option>Up to face</option></select></label>}{['pad', 'pocket', 'revolution'].includes(activeCommand || '') && <label className="check-row"><input type="checkbox" checked={activeTask?.draft.reversed === true} onChange={(event) => facade.task.update({ reversed: event.target.checked })} /><span>Reversed</span></label>}{activeCommand === 'pad' && <label className="check-row"><input type="checkbox" checked={activeTask?.draft.midplane === true} onChange={(event) => facade.task.update({ midplane: event.target.checked })} /><span>Symmetric to plane</span></label>}{!activeCommand || activeCommand === 'create-sketch' ? <><label className="field-label">Primary value <span className="field-unit">mm</span><input className="field-input" value={isSketch ? 'Fully constrained' : '42.00'} readOnly /></label><label className="field-label">Operation<select className="field-input"><option>{workbench === 'Part Design' ? 'Dimension' : 'Contextual preview'}</option><option>Through all</option><option>Up to face</option></select></label></> : null}<label className="check-row"><input type="checkbox" defaultChecked /><span>Preview result in viewport</span></label><div className="task-note"><AlertTriangle size={14} /><span>Changes remain local until the document is recomputed.</span></div></div></div>
|
||||
}
|
||||
|
||||
function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) {
|
||||
|
||||
@@ -168,6 +168,24 @@ const systemCommands = new Set(['new-document', 'save', 'select-object'])
|
||||
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
|
||||
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
|
||||
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape'])
|
||||
const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer'])
|
||||
const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution'])
|
||||
const shapeTypeIds = new Set([
|
||||
'Part::Box',
|
||||
'Part::Cylinder',
|
||||
'Part::Sphere',
|
||||
'Part::Cone',
|
||||
'Part::Fuse',
|
||||
'Part::Cut',
|
||||
'Part::Common',
|
||||
'Part::Feature',
|
||||
'PartDesign::Feature',
|
||||
'PartDesign::Pad',
|
||||
'PartDesign::Pocket',
|
||||
'PartDesign::Revolution',
|
||||
'PartDesign::Fillet',
|
||||
'PartDesign::Chamfer',
|
||||
])
|
||||
const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
'create-body': { label: 'Body', detail: 'Part Design body' },
|
||||
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
||||
@@ -182,7 +200,7 @@ const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
intersection: { label: 'Intersection', detail: 'Boolean common' },
|
||||
}
|
||||
|
||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
|
||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string, selectedTypeId?: string): CommandState => {
|
||||
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
|
||||
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
||||
if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
|
||||
@@ -190,6 +208,10 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
|
||||
if (partCommands.has(commandId) && activeWorkbench !== 'Part') return { id: commandId, status: 'disabled', reason: `Switch to Part to use ${commandId}.` }
|
||||
if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` }
|
||||
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
|
||||
if (selectedObjectId && !selectedTypeId) return { id: commandId, status: 'disabled', reason: 'The selected object no longer exists in this document.' }
|
||||
if (commandId === 'solve-sketch' && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a Sketcher sketch before solving constraints.' }
|
||||
if (shapeSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId)) return { id: commandId, status: 'disabled', reason: 'Select a solid or shape-producing feature for this operation.' }
|
||||
if (featureSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId) && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a sketch or solid feature as the Part Design source.' }
|
||||
return { id: commandId, status: 'enabled' }
|
||||
}
|
||||
|
||||
@@ -304,20 +326,22 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
}
|
||||
const objectSnapshot = createObjectSnapshot(item)
|
||||
objectSnapshot.properties = objectSnapshot.properties.map((property) => {
|
||||
if (!property.recompute || property.type === 'App::PropertyLink') return property
|
||||
if (!property.recompute) return property
|
||||
const candidate = draft[property.name.toLowerCase()]
|
||||
if (property.type === 'App::PropertyLink' && typeof candidate === 'string' && candidate.length > 0) return { ...property, value: candidate }
|
||||
if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate }
|
||||
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate }
|
||||
if (property.type === 'App::PropertyEnumeration' && typeof candidate === 'string' && property.options?.includes(candidate)) return { ...property, value: candidate }
|
||||
return property
|
||||
})
|
||||
if (isPartObject && commandId !== 'primitive') {
|
||||
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
|
||||
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && typeof draft.base !== 'string' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
|
||||
}
|
||||
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
|
||||
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
|
||||
nextDocument.dependencies = collectDependencyEdges(nextDocument)
|
||||
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
|
||||
markDocumentTouched(nextDocument, [objectId])
|
||||
return { document: nextDocument, objectId }
|
||||
}
|
||||
const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => {
|
||||
@@ -493,7 +517,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const execute = ({ commandId, payload }: ExecuteCommandInput) => {
|
||||
const requestId = `req-${++requestSequence}`
|
||||
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench }
|
||||
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId)
|
||||
const selectedTypeId = state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId
|
||||
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId)
|
||||
if (status.status === 'disabled') {
|
||||
const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED'
|
||||
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
|
||||
@@ -529,14 +554,14 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
}
|
||||
else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') }
|
||||
else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null })
|
||||
else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null })
|
||||
else if (featureCommands[commandId]) beginTask(commandId, partCommands.has(commandId) && commandId !== 'primitive' ? { source: state.selectedObjectId || null, base: state.selectedObjectId || '', tool: '' } : { source: state.selectedObjectId || null })
|
||||
emit({ type: 'command.completed', commandId, context }); emitState(); return requestId
|
||||
}
|
||||
|
||||
const facade: BitBybitWebCadFacade = {
|
||||
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
|
||||
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
|
||||
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
|
||||
|
||||
Reference in New Issue
Block a user