feat: add structured placement properties
This commit is contained in:
20
src/App.tsx
20
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 ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
|
||||
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'
|
||||
|
||||
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
|
||||
type Workbench = WorkbenchId
|
||||
@@ -451,9 +451,23 @@ 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::PropertyLinkSub') {
|
||||
const ref = property.value && typeof property.value === 'object' ? property.value : null
|
||||
const ref = property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value ? property.value : null
|
||||
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span><IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
|
||||
} 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::PropertyPlacement') {
|
||||
const placement = property.value as PlacementValue
|
||||
const updatePlacement = (path: 'position' | 'axis' | 'angle', key: 'x' | 'y' | 'z' | null, next: number) => {
|
||||
const value: PlacementValue = { position: { ...placement.position }, rotation: { axis: { ...placement.rotation.axis }, angle: placement.rotation.angle } }
|
||||
if (path === 'position' && key) value.position[key] = next
|
||||
else if (path === 'axis' && key) value.rotation.axis[key] = next
|
||||
else value.rotation.angle = next
|
||||
return commit(value)
|
||||
}
|
||||
editor = <details className="property-placement"><summary>{placement.position.x}, {placement.position.y}, {placement.position.z}</summary><div className="property-placement-grid"><span>Position</span>{(['x', 'y', 'z'] as const).map((key) => <input key={`p-${key}`} type="number" aria-label={`${property.label} position ${key}`} defaultValue={placement.position[key]} onBlur={(event) => updatePlacement('position', key, Number(event.target.value))} />)}<span>Axis</span>{(['x', 'y', 'z'] as const).map((key) => <input key={`a-${key}`} type="number" aria-label={`${property.label} axis ${key}`} defaultValue={placement.rotation.axis[key]} onBlur={(event) => updatePlacement('axis', key, Number(event.target.value))} />)}<span>Angle</span><input type="number" aria-label={`${property.label} angle`} defaultValue={placement.rotation.angle} onBlur={(event) => updatePlacement('angle', null, Number(event.target.value))} /><span /><span /></div></details>
|
||||
} else if (property.type === 'App::PropertyVector') {
|
||||
const vector = property.value as { x: number; y: number; z: number }
|
||||
editor = <div className="property-vector">{(['x', 'y', 'z'] as const).map((key) => <input key={key} type="number" aria-label={`${property.label} ${key}`} defaultValue={vector[key]} onBlur={(event) => commit({ ...vector, [key]: Number(event.target.value) })} />)}</div>
|
||||
} else if (property.type === 'App::PropertyLinkList' || property.type === 'App::PropertyStringList') editor = <input className="property-control" defaultValue={(property.value as string[]).join(', ')} aria-label={property.label} onBlur={(event) => commit(event.target.value.split(',').map((entry) => entry.trim()).filter(Boolean))} />
|
||||
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::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.name === 'Occurrences' ? 2 : property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.name === 'Occurrences' ? 100 : 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>}</>
|
||||
|
||||
@@ -4,7 +4,7 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
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, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue } 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, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, VectorValue } from './types'
|
||||
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
|
||||
export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
|
||||
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
DocumentObjectSnapshot,
|
||||
ObjectPropertySnapshot,
|
||||
PropertyValue,
|
||||
PlacementValue,
|
||||
SetPropertyInput,
|
||||
SetExpressionInput,
|
||||
RecomputeResult,
|
||||
@@ -50,6 +51,7 @@ const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
|
||||
{ name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true },
|
||||
{ name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true },
|
||||
...(item.type === 'feature' ? [{ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: false, recompute: true }] : []),
|
||||
...(item.type === 'feature' ? [{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } }, recompute: true }] : []),
|
||||
]
|
||||
|
||||
const viewProperties = (): ObjectPropertySnapshot[] => [
|
||||
@@ -143,7 +145,13 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
|
||||
const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined })
|
||||
|
||||
const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value
|
||||
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 ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
|
||||
return { ...value }
|
||||
}
|
||||
|
||||
const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({
|
||||
...document,
|
||||
@@ -159,7 +167,7 @@ const collectDependencyEdges = (document: Pick<DocumentSnapshot, 'objects'>): De
|
||||
for (const object of document.objects) {
|
||||
for (const property of object.properties) {
|
||||
if (property.type === 'App::PropertyLink' && typeof property.value === 'string' && objectIds.has(property.value)) edges.push({ sourceId: object.id, targetId: property.value, relation: 'link', propertyName: property.name })
|
||||
if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId })
|
||||
if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId })
|
||||
if (property.expression) {
|
||||
for (const reference of expressionReferences(property.expression)) {
|
||||
const separator = reference.lastIndexOf('.')
|
||||
@@ -263,6 +271,23 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
|
||||
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.`)
|
||||
if (property.type === 'App::PropertyVector') validateVectorValue(property.label, value)
|
||||
if (property.type === 'App::PropertyPlacement') {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || !('position' in value) || !('rotation' in value)) throw new TypeError(`${property.label} requires a Placement value.`)
|
||||
const placement = value as PlacementValue
|
||||
validateVectorValue(`${property.label} position`, placement.position)
|
||||
validateVectorValue(`${property.label} rotation axis`, placement.rotation?.axis)
|
||||
if (!placement.rotation || typeof placement.rotation.angle !== 'number' || !Number.isFinite(placement.rotation.angle)) throw new TypeError(`${property.label} rotation angle must be finite.`)
|
||||
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::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') {
|
||||
const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))
|
||||
if (value.some((entry) => !knownIds.has(entry))) throw new RangeError(`${property.label} contains a target that does not exist in this document.`)
|
||||
}
|
||||
}
|
||||
if (property.type === 'App::PropertyLink') {
|
||||
if (value !== null && typeof value !== 'string') throw new TypeError(`${property.label} requires an object link.`)
|
||||
const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))
|
||||
@@ -278,6 +303,12 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
|
||||
if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.')
|
||||
}
|
||||
|
||||
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>
|
||||
if (!['x', 'y', 'z'].every((key) => typeof vector[key] === 'number' && Number.isFinite(vector[key]))) throw new TypeError(`${label} components must be finite.`)
|
||||
}
|
||||
|
||||
const expressionVariables = (document: DocumentSnapshot): ReadonlyMap<string, Quantity> => {
|
||||
const variables = new Map<string, Quantity>()
|
||||
for (const object of document.objects) for (const property of object.properties) {
|
||||
|
||||
@@ -6,7 +6,13 @@ type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type
|
||||
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
|
||||
|
||||
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
|
||||
const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value
|
||||
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 ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
|
||||
return { ...value }
|
||||
}
|
||||
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
|
||||
|
||||
export interface ProjectPersistenceClient {
|
||||
|
||||
@@ -26,14 +26,21 @@ export type TopoRefValue = {
|
||||
candidates?: string[]
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | TopoRefValue | null
|
||||
export type VectorValue = { x: number; y: number; z: number }
|
||||
|
||||
export type PlacementValue = {
|
||||
position: VectorValue
|
||||
rotation: { axis: VectorValue; angle: number }
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | string[] | VectorValue | PlacementValue | 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::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::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
|
||||
value: PropertyValue
|
||||
unit?: string
|
||||
readOnly?: boolean
|
||||
|
||||
@@ -399,3 +399,9 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
|
||||
.workbench-nav { overflow-x: auto; }
|
||||
.workbench-nav .nav-more, .nav-caption { display: none; }
|
||||
}
|
||||
.property-placement { width: 100%; }
|
||||
.property-placement summary { cursor: pointer; color: var(--text); font-size: 11px; }
|
||||
.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; }
|
||||
.property-vector { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user