feat: integrate revolution into facade recompute

This commit is contained in:
2026-08-02 18:57:19 -04:00
parent d7294dfcac
commit a133970f20
8 changed files with 53 additions and 15 deletions

View File

@@ -377,7 +377,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))]
editor = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select>
} else if (property.type === 'App::PropertyColor') editor = <label className="property-color"><input type="color" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)} /><span>{String(property.value).toUpperCase()}</span></label>
else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.type === 'App::PropertyPercent' ? 100 : undefined} step={property.type === 'App::PropertyLength' ? 0.1 : 1} aria-label={property.label} onBlur={(event) => { if (!commit(Number(event.target.value))) event.target.value = String(property.value) }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /><span>{property.unit}</span></label>
else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.type === 'App::PropertyAngle' ? 360 : property.type === 'App::PropertyPercent' ? 100 : undefined} step={property.type === 'App::PropertyLength' ? 0.1 : 1} aria-label={property.label} onBlur={(event) => { if (!commit(Number(event.target.value))) event.target.value = String(property.value) }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /><span>{property.unit}</span></label>
else editor = <input className="property-control" defaultValue={String(property.value ?? '')} aria-label={property.label} onBlur={(event) => { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} />
return <><div className="property-row"><span className="property-label">{property.label}</span><div className="property-editor">{editor}</div></div>{property.expression && <div className="property-expression"><Code2 size={12} /><span>{property.expression}</span></div>}</>
}

View File

@@ -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('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('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 },
@@ -71,6 +71,11 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
]
if (item.id.startsWith('revolution')) return [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
]
if (item.id.startsWith('fillet')) return [
{ name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true },
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
@@ -137,13 +142,14 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', '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', 'fillet', 'chamfer', 'solve-sketch'])
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'fillet', 'chamfer'])
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'solve-sketch'])
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
const featureCommands: Record<string, { label: string; detail: string }> = {
'create-body': { label: 'Body', detail: 'Part Design body' },
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
pad: { label: 'Pad', detail: 'Length 42 mm' },
pocket: { label: 'Pocket', detail: 'Through all' },
revolution: { label: 'Revolution', detail: 'Angle 360 deg' },
fillet: { label: 'Fillet', detail: 'Radius 3 mm' },
chamfer: { label: 'Chamfer', detail: 'Length 2 mm' },
}
@@ -162,8 +168,9 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
if (property.readOnly) throw new Error(`${property.label} is read-only.`)
if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`)
if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`)
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
if (property.type === 'App::PropertyLength' && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`)
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle') && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`)
if (property.type === 'App::PropertyAngle' && (value as number) > 360) throw new RangeError(`${property.label} must be between 0 and 360 degrees.`)
if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`)
if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`)
if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`)
@@ -300,7 +307,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`)
const sourceProperty = sourceObject.properties[propertyIndex]
if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`)
if (!['App::PropertyLength', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`)
if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`)
const evaluated = expectedExpressionValue(sourceProperty, expression, state.document)
const references = expressionReferences(expression)
const variables = expressionVariables(state.document)

View File

@@ -1,6 +1,6 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, ShapeHandle } from './types'
import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
@@ -33,6 +33,7 @@ export type RecomputeGeometryRuntime = {
capabilities(): { status: string }
pad(input: PadInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
revolution(input: RevolutionInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
release(shape: ShapeHandle): Promise<void>
@@ -272,9 +273,9 @@ 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::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
if (!['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'
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution'
const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined
const profile = requiresProfile ? sketchProfile(profileObject?.sketch) : { profile: undefined }
if (requiresProfile && !profile.profile) return geometryFailure(object.id, profile.code || 'PROFILE_INVALID', profile.message || 'Feature profile is invalid.')
@@ -296,6 +297,16 @@ export const createFacadeGeometryRecomputeExecutor = (
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Pocket base has no valid recomputed Shape.')
result = await geometry.pocket({ ...documentContext, base: baseShape, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, throughAll: pocketType === 'Through all' })
} else if (object.typeId === 'PartDesign::Revolution') {
const angle = numberProperty('Angle', 360)
const reversed = propertyValue(object, 'Reversed') === true
result = await geometry.revolution({
...documentContext,
profile: profile.profile as PlanarProfile,
angle,
axisOrigin: [0, 0, 0],
axisDirection: reversed ? [0, -1, 0] : [0, 1, 0],
})
} else if (object.typeId === 'PartDesign::Fillet') {
const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined

View File

@@ -21,7 +21,7 @@ export type ObjectPropertySnapshot = {
label: string
group: string
scope: 'data' | 'view'
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat'
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat'
value: PropertyValue
unit?: string
readOnly?: boolean