feat: connect Part primitives and booleans
This commit is contained in:
@@ -39,7 +39,7 @@ const initialTree: ModelTreeItem[] = [
|
||||
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
|
||||
]
|
||||
|
||||
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
|
||||
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
|
||||
|
||||
const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
|
||||
{ name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label },
|
||||
@@ -58,6 +58,29 @@ const viewProperties = (): ObjectPropertySnapshot[] => [
|
||||
]
|
||||
|
||||
const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
if (item.id.startsWith('box')) return [
|
||||
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('cylinder')) return [
|
||||
{ name: 'Radius', label: 'Radius', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
|
||||
{ name: 'Height', label: 'Height', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||
{ name: 'Angle', label: 'Angle', group: 'Cylinder', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('sphere')) return [
|
||||
{ name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('cone')) return [
|
||||
{ name: 'Radius1', label: 'Bottom radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
|
||||
{ name: 'Radius2', label: 'Top radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'Height', label: 'Height', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
|
||||
{ name: 'Angle', label: 'Angle', group: 'Cone', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('union') || item.id.startsWith('cut') || item.id.startsWith('intersection')) return [
|
||||
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
|
||||
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('pad')) return [
|
||||
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' },
|
||||
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
||||
@@ -140,10 +163,11 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
|
||||
return document
|
||||
}
|
||||
|
||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'union', 'cut', 'intersection', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
||||
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', 'solve-sketch'])
|
||||
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'primitive', 'union', 'cut', 'intersection', 'solve-sketch'])
|
||||
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
|
||||
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection'])
|
||||
const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
'create-body': { label: 'Body', detail: 'Part Design body' },
|
||||
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
||||
@@ -152,6 +176,10 @@ const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
revolution: { label: 'Revolution', detail: 'Angle 360 deg' },
|
||||
fillet: { label: 'Fillet', detail: 'Radius 3 mm' },
|
||||
chamfer: { label: 'Chamfer', detail: 'Length 2 mm' },
|
||||
primitive: { label: 'Box', detail: '10 × 10 × 10 mm' },
|
||||
union: { label: 'Union', detail: 'Boolean fuse' },
|
||||
cut: { label: 'Cut', detail: 'Boolean difference' },
|
||||
intersection: { label: 'Intersection', detail: 'Boolean common' },
|
||||
}
|
||||
|
||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
|
||||
@@ -159,6 +187,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
|
||||
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.' }
|
||||
if (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` }
|
||||
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.' }
|
||||
return { id: commandId, status: 'enabled' }
|
||||
@@ -265,13 +294,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature'
|
||||
const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail }
|
||||
const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined }))
|
||||
if (type === 'body') tree.push({ ...item, children: [] })
|
||||
const isPartObject = partCommands.has(commandId)
|
||||
if (type === 'body' || isPartObject) tree.push({ ...item, children: type === 'body' ? [] : undefined })
|
||||
else {
|
||||
const body = tree.find((entry) => entry.type === 'body')
|
||||
if (body) body.children = [...(body.children || []), objectId]
|
||||
tree.push(item)
|
||||
}
|
||||
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), createObjectSnapshot(item)]
|
||||
const objectSnapshot = createObjectSnapshot(item)
|
||||
if (isPartObject && commandId !== 'primitive') {
|
||||
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
|
||||
import { cloneSketch, solveSketch } from './sketcher'
|
||||
import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
|
||||
import type { BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
|
||||
|
||||
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
|
||||
|
||||
@@ -31,6 +31,13 @@ export type RecomputeNodeExecutor = (
|
||||
|
||||
export type RecomputeGeometryRuntime = {
|
||||
capabilities(): { status: string }
|
||||
createBox(input: CreateBoxInput): Promise<ShapeHandle>
|
||||
createCylinder(input: CreateCylinderInput): Promise<ShapeHandle>
|
||||
createSphere(input: CreateSphereInput): Promise<ShapeHandle>
|
||||
createCone(input: CreateConeInput): Promise<ShapeHandle>
|
||||
union(input: BooleanUnionInput): Promise<ShapeHandle>
|
||||
cut(input: BooleanCutInput): Promise<ShapeHandle>
|
||||
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
|
||||
pad(input: PadInput): Promise<ShapeHandle>
|
||||
pocket(input: PocketInput): Promise<ShapeHandle>
|
||||
revolution(input: RevolutionInput): Promise<ShapeHandle>
|
||||
@@ -273,7 +280,7 @@ export const createFacadeGeometryRecomputeExecutor = (
|
||||
): RecomputeNodeExecutor => async (object, document, context) => {
|
||||
const base = await executeFacadeRecomputeNode(object, document, context)
|
||||
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
|
||||
if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
|
||||
if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
|
||||
|
||||
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution'
|
||||
const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined
|
||||
@@ -288,7 +295,24 @@ export const createFacadeGeometryRecomputeExecutor = (
|
||||
const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion }
|
||||
try {
|
||||
let result: ShapeHandle
|
||||
if (object.typeId === 'PartDesign::Pad') {
|
||||
if (object.typeId === 'Part::Box') {
|
||||
result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) })
|
||||
} else if (object.typeId === 'Part::Cylinder') {
|
||||
result = await geometry.createCylinder({ ...documentContext, radius: numberProperty('Radius', 5), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) })
|
||||
} else if (object.typeId === 'Part::Sphere') {
|
||||
result = await geometry.createSphere({ ...documentContext, radius: numberProperty('Radius', 5) })
|
||||
} else if (object.typeId === 'Part::Cone') {
|
||||
result = await geometry.createCone({ ...documentContext, radius1: numberProperty('Radius1', 5), radius2: numberProperty('Radius2', 0), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) })
|
||||
} else if (object.typeId === 'Part::Fuse' || object.typeId === 'Part::Cut' || object.typeId === 'Part::Common') {
|
||||
const baseObject = linkedObject(object, 'Base', document)
|
||||
const toolObject = linkedObject(object, 'Tool', document)
|
||||
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||
const toolShape = toolObject ? shapes.get(toolObject.id) : undefined
|
||||
if (!baseShape || !toolShape) return geometryFailure(object.id, 'BOOLEAN_SHAPE_MISSING', 'Boolean operation requires recomputed Base and Tool shapes.')
|
||||
if (object.typeId === 'Part::Fuse') result = await geometry.union({ ...documentContext, shapes: [baseShape, toolShape] })
|
||||
else if (object.typeId === 'Part::Cut') result = await geometry.cut({ ...documentContext, base: baseShape, tools: [toolShape] })
|
||||
else result = await geometry.intersection({ ...documentContext, shapes: [baseShape, toolShape] })
|
||||
} else if (object.typeId === 'PartDesign::Pad') {
|
||||
result = await geometry.pad({ ...documentContext, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, symmetricToPlane: propertyValue(object, 'Midplane') === true })
|
||||
} else if (object.typeId === 'PartDesign::Pocket') {
|
||||
const pocketType = propertyValue(object, 'Type')
|
||||
|
||||
Reference in New Issue
Block a user