feat: add structured multi-transform
This commit is contained in:
47
src/App.tsx
47
src/App.tsx
@@ -54,7 +54,7 @@ import {
|
||||
ZoomOut,
|
||||
} from 'lucide-react'
|
||||
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
|
||||
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
|
||||
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
|
||||
|
||||
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
|
||||
type Workbench = WorkbenchId
|
||||
@@ -344,6 +344,45 @@ function TreeItem({ item, level, expanded, onToggle, itemsById, selectedObject,
|
||||
</>
|
||||
}
|
||||
|
||||
const defaultMultiTransform: MultiTransformValue = { steps: [{ id: 'linear-1', type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] }
|
||||
|
||||
function multiTransformValue(value: unknown): MultiTransformValue {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || !('steps' in value) || !Array.isArray(value.steps) || value.steps.length === 0) return { steps: defaultMultiTransform.steps.map((step) => ({ ...step })) }
|
||||
return { steps: value.steps.map((step) => ({ ...(step as MultiTransformStep) })) }
|
||||
}
|
||||
|
||||
function MultiTransformEditor({ value, onChange }: { value: MultiTransformValue; onChange: (value: MultiTransformValue) => void }) {
|
||||
const updateStep = (index: number, update: Partial<MultiTransformStep>) => onChange({ steps: value.steps.map((step, stepIndex) => stepIndex === index ? { ...step, ...update } as MultiTransformStep : { ...step }) })
|
||||
const replaceStep = (index: number, type: MultiTransformStep['type']) => {
|
||||
const used = new Set(value.steps.map((step) => step.id))
|
||||
let sequence = index + 1
|
||||
while (used.has(`${type}-${sequence}`) && value.steps[index].id !== `${type}-${sequence}`) sequence += 1
|
||||
const id = `${type}-${sequence}`
|
||||
const step: MultiTransformStep = type === 'linear'
|
||||
? { id, type, occurrences: 2, length: 20, direction: 'Horizontal' }
|
||||
: type === 'polar'
|
||||
? { id, type, occurrences: 3, angle: 360, axis: 'Normal' }
|
||||
: { id, type, plane: 'YZ plane' }
|
||||
onChange({ steps: value.steps.map((candidate, stepIndex) => stepIndex === index ? step : { ...candidate }) })
|
||||
}
|
||||
const addStep = () => {
|
||||
if (value.steps.length >= 6) return
|
||||
const used = new Set(value.steps.map((step) => step.id))
|
||||
let sequence = value.steps.length + 1
|
||||
while (used.has(`linear-${sequence}`)) sequence += 1
|
||||
onChange({ steps: [...value.steps.map((step) => ({ ...step })), { id: `linear-${sequence}`, type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] })
|
||||
}
|
||||
return <div className="multi-transform-editor">
|
||||
{value.steps.map((step, index) => <div className="multi-transform-step" key={step.id}>
|
||||
<div className="multi-transform-step-head"><span>Step {index + 1}</span><select aria-label={`Transformation step ${index + 1} type`} value={step.type} onChange={(event) => replaceStep(index, event.target.value as MultiTransformStep['type'])}><option value="linear">Linear</option><option value="polar">Polar</option><option value="mirrored">Mirrored</option></select><button type="button" className="multi-transform-remove" title="Remove transformation step" aria-label={`Remove transformation step ${index + 1}`} disabled={value.steps.length === 1} onClick={() => onChange({ steps: value.steps.filter((_, stepIndex) => stepIndex !== index).map((candidate) => ({ ...candidate })) })}><X size={14} /></button></div>
|
||||
{step.type === 'linear' && <div className="multi-transform-fields"><label>Count<input type="number" min={2} max={10} step={1} value={step.occurrences} onChange={(event) => updateStep(index, { occurrences: Number(event.target.value) })} /></label><label>Length<input type="number" min={0.001} step={0.1} value={step.length} onChange={(event) => updateStep(index, { length: Number(event.target.value) })} /></label><label>Direction<select value={step.direction} onChange={(event) => updateStep(index, { direction: event.target.value as 'Horizontal' | 'Vertical' | 'Normal' })}><option>Horizontal</option><option>Vertical</option><option>Normal</option></select></label></div>}
|
||||
{step.type === 'polar' && <div className="multi-transform-fields"><label>Count<input type="number" min={2} max={10} step={1} value={step.occurrences} onChange={(event) => updateStep(index, { occurrences: Number(event.target.value) })} /></label><label>Angle<input type="number" min={0.001} max={360} step={1} value={step.angle} onChange={(event) => updateStep(index, { angle: Number(event.target.value) })} /></label><label>Axis<select value={step.axis} onChange={(event) => updateStep(index, { axis: event.target.value as 'Horizontal' | 'Vertical' | 'Normal' })}><option>Normal</option><option>Horizontal</option><option>Vertical</option></select></label></div>}
|
||||
{step.type === 'mirrored' && <div className="multi-transform-fields multi-transform-fields-single"><label>Plane<select value={step.plane} onChange={(event) => updateStep(index, { plane: event.target.value as 'XY plane' | 'XZ plane' | 'YZ plane' })}><option>XY plane</option><option>XZ plane</option><option>YZ plane</option></select></label></div>}
|
||||
</div>)}
|
||||
<button type="button" className="multi-transform-add" disabled={value.steps.length >= 6} onClick={addStep}><Plus size={14} />Add step</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
|
||||
const definition = workbenchDefinitions[workbench]
|
||||
const isSketch = workbench === 'Sketcher'
|
||||
@@ -362,9 +401,10 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
|
||||
}
|
||||
const currentFeatureField = activeTask?.commandId ? featureFields[activeTask.commandId] : undefined
|
||||
const activeCommand = activeTask?.commandId
|
||||
const multiTransform = multiTransformValue(activeTask?.draft.transformations)
|
||||
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', 'PartDesign::Mirrored', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId))
|
||||
const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::Mirrored', 'PartDesign::MultiTransform', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].includes(object.typeId))
|
||||
const objectLabel = (object: DocumentSnapshot['objects'][number]) => { const value = object.properties.find((property) => property.name === 'Label')?.value; return typeof value === 'string' || typeof value === 'number' ? String(value) : object.id }
|
||||
const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : ''
|
||||
const acceptTask = () => {
|
||||
@@ -397,6 +437,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
|
||||
<label className="field-label">Mirror plane<select className="field-input" value={typeof activeTask?.draft.plane === 'string' ? activeTask.draft.plane : 'YZ plane'} onChange={(event) => facade.task.update({ plane: event.target.value })}><option>XY plane</option><option>XZ plane</option><option>YZ plane</option></select></label>
|
||||
<label className="check-row"><input type="checkbox" checked={activeTask?.draft.fuse !== false} onChange={(event) => facade.task.update({ fuse: event.target.checked })} /><span>Fuse result</span></label>
|
||||
</>}
|
||||
{activeCommand === 'multi-transform' && <MultiTransformEditor value={multiTransform} onChange={(transformations) => facade.task.update({ transformations })} />}
|
||||
{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 === 'linear-pattern' && <>
|
||||
<label className="field-label">Occurrences<input className="field-input" type="number" min={2} max={100} step={1} value={typeof activeTask?.draft.occurrences === 'number' ? Number(activeTask.draft.occurrences) : 2} onChange={(event) => facade.task.update({ occurrences: Number(event.target.value) })} /></label>
|
||||
@@ -466,6 +507,8 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
|
||||
} catch (error) { showNotice(error instanceof Error ? error.message : String(error)) }
|
||||
}
|
||||
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span>{(ref.status === 'ambiguous' || ref.status === 'deleted') && candidates.length > 0 ? <select className="property-control property-topology-candidates" aria-label={`${property.label} replacement`} defaultValue="" onChange={(event) => replace(event.target.value)}><option value="">Replace</option>{candidates.map((candidate) => <option value={candidate} key={candidate}>{candidate}</option>)}</select> : null}<IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
|
||||
} else if (property.type === 'App::PropertyMultiTransform') {
|
||||
editor = <MultiTransformEditor value={multiTransformValue(property.value)} onChange={commit} />
|
||||
} else if (property.type === 'App::PropertyPlacement') {
|
||||
const placement = property.value as PlacementValue
|
||||
const updatePlacement = (path: 'position' | 'axis' | 'angle', key: 'x' | 'y' | 'z' | null, next: number) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION,
|
||||
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
|
||||
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
|
||||
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, ModelTreeItem, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, ModelTreeItem, MultiTransformStep, MultiTransformValue, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
|
||||
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
|
||||
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences'
|
||||
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
FacadeRequestContext,
|
||||
FacadeState,
|
||||
ModelTreeItem,
|
||||
MultiTransformValue,
|
||||
DocumentObjectSnapshot,
|
||||
ObjectPropertySnapshot,
|
||||
PropertyValue,
|
||||
@@ -45,7 +46,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('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.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : 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.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : 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 },
|
||||
@@ -121,6 +122,10 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
{ name: 'Plane', label: 'Mirror plane', group: 'Mirrored', scope: 'data', type: 'App::PropertyEnumeration', value: 'YZ plane', options: ['XY plane', 'XZ plane', 'YZ plane'], recompute: true },
|
||||
{ name: 'Fuse', label: 'Fuse result', group: 'Mirrored', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('multi-transform')) return [
|
||||
{ name: 'Base', label: 'Base feature', group: 'Multi-transform', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
|
||||
{ name: 'Transformations', label: 'Transformations', group: 'Multi-transform', scope: 'data', type: 'App::PropertyMultiTransform', value: { steps: [{ id: 'linear-1', type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] }, recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('linear-pattern')) return [
|
||||
{ name: 'Base', label: 'Base', group: 'Pattern', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
|
||||
{ name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data', type: 'App::PropertyInteger', value: 2, recompute: true },
|
||||
@@ -155,6 +160,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => {
|
||||
if (Array.isArray(value)) return [...value]
|
||||
if (!value || typeof value !== 'object') return value
|
||||
if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } }
|
||||
if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) }
|
||||
if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
|
||||
return { ...value }
|
||||
}
|
||||
@@ -207,12 +213,12 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
|
||||
return document
|
||||
}
|
||||
|
||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'union', 'cut', 'intersection', 'check-shape', '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', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
|
||||
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole'])
|
||||
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
|
||||
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole'])
|
||||
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape'])
|
||||
const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'mirrored', 'linear-pattern', 'polar-pattern', 'hole'])
|
||||
const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole'])
|
||||
const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution'])
|
||||
const shapeTypeIds = new Set([
|
||||
'Part::Box',
|
||||
@@ -230,6 +236,7 @@ const shapeTypeIds = new Set([
|
||||
'PartDesign::Fillet',
|
||||
'PartDesign::Chamfer',
|
||||
'PartDesign::Mirrored',
|
||||
'PartDesign::MultiTransform',
|
||||
'PartDesign::LinearPattern',
|
||||
'PartDesign::PolarPattern',
|
||||
'PartDesign::Hole',
|
||||
@@ -243,6 +250,7 @@ const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
fillet: { label: 'Fillet', detail: 'Radius 3 mm' },
|
||||
chamfer: { label: 'Chamfer', detail: 'Length 2 mm' },
|
||||
mirrored: { label: 'Mirrored', detail: 'Whole Shape across YZ plane' },
|
||||
'multi-transform': { label: 'Multi-transform', detail: 'Ordered whole-shape transformations' },
|
||||
'linear-pattern': { label: 'Linear Pattern', detail: '2 occurrences over 20 mm' },
|
||||
'polar-pattern': { label: 'Polar Pattern', detail: '3 occurrences over 360 deg' },
|
||||
hole: { label: 'Hole', detail: 'Simple 5 mm diameter hole' },
|
||||
@@ -290,6 +298,7 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
|
||||
const axis = placement.rotation.axis
|
||||
if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`)
|
||||
}
|
||||
if (property.type === 'App::PropertyMultiTransform') validateMultiTransformValue(value)
|
||||
if (property.type === 'App::PropertyLinkList' || property.type === 'App::PropertyStringList') {
|
||||
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) throw new TypeError(`${property.label} requires a string list.`)
|
||||
if (property.type === 'App::PropertyLinkList') {
|
||||
@@ -312,6 +321,34 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
|
||||
if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.')
|
||||
}
|
||||
|
||||
function validateMultiTransformValue(value: unknown): asserts value is MultiTransformValue {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || !('steps' in value) || !Array.isArray(value.steps)) throw new TypeError('Transformations requires an ordered step list.')
|
||||
if (value.steps.length < 1 || value.steps.length > 6) throw new RangeError('Transformations requires between 1 and 6 steps.')
|
||||
const ids = new Set<string>()
|
||||
let instances = 1
|
||||
for (const rawStep of value.steps) {
|
||||
if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep)) throw new TypeError('Each transformation step requires a structured value.')
|
||||
const step = rawStep as Record<string, unknown>
|
||||
if (typeof step.id !== 'string' || !step.id.trim() || ids.has(step.id)) throw new RangeError('Transformation step IDs must be non-empty and unique.')
|
||||
ids.add(step.id)
|
||||
if (step.type === 'linear') {
|
||||
if (!Number.isSafeInteger(step.occurrences) || (step.occurrences as number) < 2 || (step.occurrences as number) > 10) throw new RangeError('Linear occurrences must be an integer between 2 and 10.')
|
||||
if (typeof step.length !== 'number' || !Number.isFinite(step.length) || step.length <= 0) throw new RangeError('Linear length must be greater than zero.')
|
||||
if (!['Horizontal', 'Vertical', 'Normal'].includes(String(step.direction))) throw new RangeError('Linear direction is invalid.')
|
||||
instances *= step.occurrences as number
|
||||
} else if (step.type === 'polar') {
|
||||
if (!Number.isSafeInteger(step.occurrences) || (step.occurrences as number) < 2 || (step.occurrences as number) > 10) throw new RangeError('Polar occurrences must be an integer between 2 and 10.')
|
||||
if (typeof step.angle !== 'number' || !Number.isFinite(step.angle) || step.angle <= 0 || step.angle > 360) throw new RangeError('Polar angle must be greater than zero and no more than 360 degrees.')
|
||||
if (!['Horizontal', 'Vertical', 'Normal'].includes(String(step.axis))) throw new RangeError('Polar axis is invalid.')
|
||||
instances *= step.occurrences as number
|
||||
} else if (step.type === 'mirrored') {
|
||||
if (!['XY plane', 'XZ plane', 'YZ plane'].includes(String(step.plane))) throw new RangeError('Mirror plane is invalid.')
|
||||
instances *= 2
|
||||
} else throw new RangeError('Transformation step type is invalid.')
|
||||
if (instances > 100) throw new RangeError('Multi-transform cannot create more than 100 instances.')
|
||||
}
|
||||
}
|
||||
|
||||
function validateVectorValue(label: string, value: unknown): asserts value is { x: number; y: number; z: number } {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} requires a Vector value.`)
|
||||
const vector = value as Record<string, unknown>
|
||||
@@ -388,7 +425,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
|
||||
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
|
||||
const emitState = () => emit({ type: 'state.changed', state: getState() })
|
||||
const getState = () => ({ ...state, diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
|
||||
const getState = () => ({ ...state, diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: Object.fromEntries(Object.entries(state.task.draft).map(([key, value]) => [key, key === 'transformations' ? clonePropertyValue(value as MultiTransformValue) : value])) } : null })
|
||||
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() }
|
||||
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
|
||||
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
|
||||
@@ -424,6 +461,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Profile' && sourceObject.typeId === 'Sketcher::SketchObject') return { ...property, value: sourceObject.id }
|
||||
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Base' && shapeTypeIds.has(sourceObject.typeId)) return { ...property, value: sourceObject.id }
|
||||
if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate }
|
||||
if (property.type === 'App::PropertyMultiTransform' && candidate && typeof candidate === 'object') return { ...property, value: clonePropertyValue(candidate as MultiTransformValue) }
|
||||
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') && 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
|
||||
|
||||
@@ -11,6 +11,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => {
|
||||
if (Array.isArray(value)) return [...value]
|
||||
if (!value || typeof value !== 'object') return value
|
||||
if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } }
|
||||
if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) }
|
||||
if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
|
||||
return { ...value }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
|
||||
import { cloneSketch, solveSketch } from './sketcher'
|
||||
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types'
|
||||
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, MultiTransformStep, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types'
|
||||
import { captureSignatureTopologyHistory } from './topologyHistory'
|
||||
import { migrateTopoRefs } from './topologyReferences'
|
||||
|
||||
@@ -369,6 +369,40 @@ const sketchProfile = (sketch: DocumentObjectSnapshot['sketch']): { profile?: Pl
|
||||
|
||||
const geometryFailure = (objectId: string, code: string, message: string): RecomputeNodeResult => ({ status: 'failed', errors: [{ objectId, code, message }] })
|
||||
|
||||
const multiTransformSteps = (object: DocumentObjectSnapshot): { steps: MultiTransformStep[] } | { error: string } => {
|
||||
const value = propertyValue(object, 'Transformations')
|
||||
if (!structuredValue(value)) return { error: 'Transformations requires an ordered step list.' }
|
||||
const record = value as Record<string, unknown>
|
||||
if (!Array.isArray(record.steps)) return { error: 'Transformations requires an ordered step list.' }
|
||||
if (record.steps.length < 1 || record.steps.length > 6) return { error: 'Transformations requires between 1 and 6 steps.' }
|
||||
const ids = new Set<string>()
|
||||
const steps: MultiTransformStep[] = []
|
||||
let instances = 1
|
||||
for (const rawStep of record.steps) {
|
||||
if (!structuredValue(rawStep) || typeof rawStep.id !== 'string' || !rawStep.id.trim() || ids.has(rawStep.id)) return { error: 'Transformation step IDs must be non-empty and unique.' }
|
||||
ids.add(rawStep.id)
|
||||
if (rawStep.type === 'linear') {
|
||||
if (!Number.isSafeInteger(rawStep.occurrences) || (rawStep.occurrences as number) < 2 || (rawStep.occurrences as number) > 10) return { error: 'Linear occurrences must be an integer between 2 and 10.' }
|
||||
if (typeof rawStep.length !== 'number' || !Number.isFinite(rawStep.length) || rawStep.length <= 0) return { error: 'Linear length must be greater than zero.' }
|
||||
if (!['Horizontal', 'Vertical', 'Normal'].includes(String(rawStep.direction))) return { error: 'Linear direction is invalid.' }
|
||||
instances *= rawStep.occurrences as number
|
||||
steps.push({ id: rawStep.id, type: 'linear', occurrences: rawStep.occurrences as number, length: rawStep.length, direction: rawStep.direction as 'Horizontal' | 'Vertical' | 'Normal' })
|
||||
} else if (rawStep.type === 'polar') {
|
||||
if (!Number.isSafeInteger(rawStep.occurrences) || (rawStep.occurrences as number) < 2 || (rawStep.occurrences as number) > 10) return { error: 'Polar occurrences must be an integer between 2 and 10.' }
|
||||
if (typeof rawStep.angle !== 'number' || !Number.isFinite(rawStep.angle) || rawStep.angle <= 0 || rawStep.angle > 360) return { error: 'Polar angle must be greater than zero and no more than 360 degrees.' }
|
||||
if (!['Horizontal', 'Vertical', 'Normal'].includes(String(rawStep.axis))) return { error: 'Polar axis is invalid.' }
|
||||
instances *= rawStep.occurrences as number
|
||||
steps.push({ id: rawStep.id, type: 'polar', occurrences: rawStep.occurrences as number, angle: rawStep.angle, axis: rawStep.axis as 'Horizontal' | 'Vertical' | 'Normal' })
|
||||
} else if (rawStep.type === 'mirrored') {
|
||||
if (!['XY plane', 'XZ plane', 'YZ plane'].includes(String(rawStep.plane))) return { error: 'Mirror plane is invalid.' }
|
||||
instances *= 2
|
||||
steps.push({ id: rawStep.id, type: 'mirrored', plane: rawStep.plane as 'XY plane' | 'XZ plane' | 'YZ plane' })
|
||||
} else return { error: 'Transformation step type is invalid.' }
|
||||
if (instances > 100) return { error: 'Multi-transform cannot create more than 100 instances.' }
|
||||
}
|
||||
return { steps }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds real OCCT feature execution without putting transient ShapeHandles in the
|
||||
* persisted document snapshot. The map is deliberately owned by the Facade and
|
||||
@@ -386,7 +420,7 @@ export const createFacadeGeometryRecomputeExecutor = (
|
||||
return base
|
||||
}
|
||||
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') 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', 'PartDesign::Mirrored', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].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', 'PartDesign::Mirrored', 'PartDesign::MultiTransform', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole'].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
|
||||
@@ -456,6 +490,40 @@ export const createFacadeGeometryRecomputeExecutor = (
|
||||
try { result = await geometry.union({ ...documentContext, shapes: [baseShape, mirrored] }) }
|
||||
finally { await Promise.allSettled([geometry.release(mirrored)]) }
|
||||
}
|
||||
} else if (object.typeId === 'PartDesign::MultiTransform') {
|
||||
const baseObject = linkedObject(object, 'Base', document)
|
||||
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Multi-transform base has no valid recomputed Shape.')
|
||||
const parsed = multiTransformSteps(object)
|
||||
if ('error' in parsed) return geometryFailure(object.id, 'MULTITRANSFORM_INVALID', parsed.error)
|
||||
if (parsed.steps.some((step) => step.type === 'mirrored') && !geometry.mirror) return geometryFailure(object.id, 'MULTITRANSFORM_MIRROR_UNAVAILABLE', 'The geometry runtime does not provide plane mirroring.')
|
||||
let instances: ShapeHandle[] = [baseShape]
|
||||
const owned = new Map<string, ShapeHandle>()
|
||||
const own = (shape: ShapeHandle) => { if (shape.id !== baseShape.id) owned.set(shape.id, shape); return shape }
|
||||
try {
|
||||
for (const step of parsed.steps) {
|
||||
const parents = [...instances]
|
||||
const created: ShapeHandle[] = []
|
||||
if (step.type === 'mirrored') {
|
||||
const normal: [number, number, number] = step.plane === 'XZ plane' ? [0, 1, 0] : step.plane === 'YZ plane' ? [1, 0, 0] : [0, 0, 1]
|
||||
for (const parent of parents) created.push(own(await geometry.mirror!({ ...documentContext, shape: parent, origin: [0, 0, 0], normal })))
|
||||
} else if (step.type === 'linear') {
|
||||
const axis: [number, number, number] = step.direction === 'Vertical' ? [0, 1, 0] : step.direction === 'Normal' ? [0, 0, 1] : [1, 0, 0]
|
||||
for (const parent of parents) for (let index = 1; index < step.occurrences; index += 1) {
|
||||
const offset = step.length * index / (step.occurrences - 1)
|
||||
created.push(own(await geometry.applyPlacement({ ...documentContext, shape: parent, placement: { translation: [axis[0] * offset, axis[1] * offset, axis[2] * offset], rotationAxis: [0, 0, 1], rotationAngle: 0 } })))
|
||||
}
|
||||
} else {
|
||||
const axis: [number, number, number] = step.axis === 'Horizontal' ? [1, 0, 0] : step.axis === 'Vertical' ? [0, 1, 0] : [0, 0, 1]
|
||||
const angleStep = step.angle === 360 ? step.angle / step.occurrences : step.angle / (step.occurrences - 1)
|
||||
for (const parent of parents) for (let index = 1; index < step.occurrences; index += 1) created.push(own(await geometry.applyPlacement({ ...documentContext, shape: parent, placement: { translation: [0, 0, 0], rotationAxis: axis, rotationAngle: angleStep * index } })))
|
||||
}
|
||||
instances = [...parents, ...created]
|
||||
}
|
||||
result = await geometry.union({ ...documentContext, shapes: instances })
|
||||
} finally {
|
||||
await Promise.allSettled([...owned.values()].map((shape) => geometry.release(shape)))
|
||||
}
|
||||
} else if (object.typeId === 'PartDesign::LinearPattern') {
|
||||
const baseObject = linkedObject(object, 'Base', document)
|
||||
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||
|
||||
@@ -33,14 +33,21 @@ export type PlacementValue = {
|
||||
rotation: { axis: VectorValue; angle: number }
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | string[] | VectorValue | PlacementValue | TopoRefValue | null
|
||||
export type MultiTransformStep =
|
||||
| { id: string; type: 'linear'; occurrences: number; length: number; direction: 'Horizontal' | 'Vertical' | 'Normal' }
|
||||
| { id: string; type: 'polar'; occurrences: number; angle: number; axis: 'Horizontal' | 'Vertical' | 'Normal' }
|
||||
| { id: string; type: 'mirrored'; plane: 'XY plane' | 'XZ plane' | 'YZ plane' }
|
||||
|
||||
export type MultiTransformValue = { steps: MultiTransformStep[] }
|
||||
|
||||
export type PropertyValue = string | number | boolean | string[] | VectorValue | PlacementValue | MultiTransformValue | TopoRefValue | null
|
||||
|
||||
export type ObjectPropertySnapshot = {
|
||||
name: string
|
||||
label: string
|
||||
group: string
|
||||
scope: 'data' | 'view'
|
||||
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
|
||||
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
|
||||
value: PropertyValue
|
||||
unit?: string
|
||||
readOnly?: boolean
|
||||
|
||||
@@ -406,4 +406,17 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
|
||||
.property-placement-grid { display: grid; grid-template-columns: 42px repeat(3, minmax(0, 1fr)); gap: 4px; margin-top: 6px; align-items: center; }
|
||||
.property-placement-grid span { color: var(--text-muted); font-size: 10px; }
|
||||
.property-placement-grid input, .property-vector input { min-width: 0; width: 100%; height: 24px; border: 1px solid var(--line); background: var(--bg-soft); color: var(--text); padding: 2px 4px; }
|
||||
.multi-transform-editor { width: 100%; display: grid; gap: 7px; margin-bottom: 12px; }
|
||||
.multi-transform-step { width: 100%; border: 1px solid var(--line); border-radius: 4px; background: var(--bg-soft); padding: 7px; }
|
||||
.multi-transform-step-head { display: grid; grid-template-columns: minmax(42px, 1fr) minmax(82px, 1.4fr) 24px; gap: 5px; align-items: center; color: var(--text-muted); font-size: 10px; }
|
||||
.multi-transform-step-head select, .multi-transform-fields input, .multi-transform-fields select { min-width: 0; width: 100%; height: 25px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); color: var(--text); padding: 0 5px; font-size: 10px; }
|
||||
.multi-transform-remove { width: 24px; height: 24px; display: grid; place-items: center; border: 0; border-radius: 3px; background: transparent; color: var(--text-muted); cursor: pointer; }
|
||||
.multi-transform-remove:hover:not(:disabled) { color: var(--red); background: var(--red-soft); }
|
||||
.multi-transform-remove:disabled { opacity: .35; cursor: default; }
|
||||
.multi-transform-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 7px; }
|
||||
.multi-transform-fields-single { grid-template-columns: minmax(0, 1fr); }
|
||||
.multi-transform-fields label { min-width: 0; display: grid; gap: 3px; color: var(--text-muted); font-size: 9px; }
|
||||
.multi-transform-add { width: 100%; height: 28px; display: flex; align-items: center; justify-content: center; gap: 5px; border: 1px dashed var(--line); border-radius: 3px; background: transparent; color: var(--text-soft); font-size: 10px; cursor: pointer; }
|
||||
.multi-transform-add:hover:not(:disabled) { border-color: var(--cyan); color: var(--cyan); }
|
||||
.multi-transform-add:disabled { opacity: .4; cursor: default; }
|
||||
.property-vector { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user