@things-factory/figure-ui 10.1.49 → 10.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/graphql/v3-store.ts +2 -1
- package/client/modeller/figure-history-panel.ts +9 -1
- package/client/modeller/figure-v3-first-save.ts +98 -0
- package/client/modeller/figure-v3-links.ts +89 -26
- package/client/modeller/figure-v3-parts.ts +1 -0
- package/client/modeller/figure-v3-properties.ts +2 -1
- package/client/modeller/v3-canvas.ts +61 -7
- package/client/modeller/v3-editor.ts +91 -21
- package/client/modeller/v3-first-save.ts +34 -0
- package/client/modeller/v3-gizmo.ts +52 -3
- package/client/modeller/v3-joint-drag.ts +116 -0
- package/client/modeller/v3-joint-fan.ts +24 -11
- package/client/modeller/v3-keys.ts +12 -1
- package/client/modeller/v3-links.ts +72 -5
- package/client/modeller/v3-live.ts +11 -0
- package/client/modeller/v3-product-session.ts +4 -2
- package/client/modeller/v3-refusals.ts +21 -0
- package/client/pages/figure-v3-modeller-page.ts +46 -7
- package/dist-client/graphql/v3-store.js +2 -1
- package/dist-client/graphql/v3-store.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.js +9 -1
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
- package/dist-client/modeller/figure-v3-first-save.js +118 -0
- package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +5 -0
- package/dist-client/modeller/figure-v3-links.js +81 -23
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.js +1 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.js +2 -1
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +11 -0
- package/dist-client/modeller/v3-canvas.js +61 -7
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +13 -1
- package/dist-client/modeller/v3-editor.js +91 -21
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-first-save.d.ts +17 -0
- package/dist-client/modeller/v3-first-save.js +27 -0
- package/dist-client/modeller/v3-first-save.js.map +1 -0
- package/dist-client/modeller/v3-gizmo.d.ts +18 -0
- package/dist-client/modeller/v3-gizmo.js +45 -2
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
- package/dist-client/modeller/v3-joint-drag.js +97 -0
- package/dist-client/modeller/v3-joint-drag.js.map +1 -0
- package/dist-client/modeller/v3-joint-fan.js +24 -11
- package/dist-client/modeller/v3-joint-fan.js.map +1 -1
- package/dist-client/modeller/v3-keys.d.ts +9 -1
- package/dist-client/modeller/v3-keys.js +3 -0
- package/dist-client/modeller/v3-keys.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +45 -2
- package/dist-client/modeller/v3-links.js +52 -3
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +6 -0
- package/dist-client/modeller/v3-live.js +10 -0
- package/dist-client/modeller/v3-live.js.map +1 -1
- package/dist-client/modeller/v3-product-session.d.ts +4 -1
- package/dist-client/modeller/v3-product-session.js +2 -2
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-refusals.d.ts +4 -0
- package/dist-client/modeller/v3-refusals.js +21 -0
- package/dist-client/modeller/v3-refusals.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
- package/dist-client/pages/figure-v3-modeller-page.js +45 -7
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -5
- package/test/browser/build.mjs +62 -0
- package/test/browser/modeller-page.js +198 -0
- package/test/browser/modeller.check.mjs +334 -0
- package/test/browser/serve.mjs +43 -0
- package/test/joints.ts +33 -0
- package/test/v3-clips.test.ts +3 -2
- package/test/v3-controls.test.ts +3 -2
- package/test/v3-fields.test.ts +3 -2
- package/test/v3-first-save.test.ts +75 -0
- package/test/v3-gizmo.test.ts +38 -5
- package/test/v3-joint-drag.test.ts +93 -0
- package/test/v3-joint-fan.test.ts +16 -6
- package/test/v3-keys.test.ts +15 -2
- package/test/v3-links.test.ts +62 -14
- package/test/v3-live.test.ts +23 -4
- package/test/v3-maturity.test.ts +2 -6
- package/test/v3-refusals.test.ts +28 -0
- package/translations/en.json +33 -5
- package/translations/ja.json +32 -4
- package/translations/ko.json +32 -4
- package/translations/ms.json +32 -4
- package/translations/zh.json +32 -4
|
@@ -22,16 +22,20 @@ import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from '.
|
|
|
22
22
|
import { keyActionOf } from './v3-keys.js'
|
|
23
23
|
import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
24
24
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
25
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
25
26
|
import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
26
27
|
import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
|
|
27
28
|
import { paletteRecipe } from './v3-palette.js'
|
|
28
29
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
29
30
|
import { nudgeOf } from './v3-stage-model.js'
|
|
31
|
+
import { sizeRangeRefusalOf } from './v3-refusals.js'
|
|
30
32
|
import type { V3RenderPart } from './v3-renderer.js'
|
|
31
33
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
32
34
|
import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
|
|
33
35
|
import { jointFanOf } from './v3-joint-fan.js'
|
|
34
|
-
import {
|
|
36
|
+
import { jointDragOf } from './v3-joint-drag.js'
|
|
37
|
+
import type { FirstSave } from './v3-first-save.js'
|
|
38
|
+
import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -44,7 +48,8 @@ type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'hi
|
|
|
44
48
|
/** Product editor surface. The injected session is shared by page persistence and AI commands. */
|
|
45
49
|
@customElement('figure-v3-editor')
|
|
46
50
|
export class FigureV3Editor extends LitElement {
|
|
47
|
-
|
|
51
|
+
/* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
|
|
52
|
+
static styles = [ScrollbarStyles, SidePanelStyles, css`
|
|
48
53
|
:host {
|
|
49
54
|
display: flex;
|
|
50
55
|
flex-direction: column;
|
|
@@ -226,6 +231,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
226
231
|
@property({ type: Number }) instances?: number
|
|
227
232
|
/** The cost line the parts list shows, from the maturity report. */
|
|
228
233
|
@property({ attribute: false }) budget?: V3BudgetLine
|
|
234
|
+
/**
|
|
235
|
+
* Asks the type and name of a figure saved for the first time (the host has the catalogue's names); undefined
|
|
236
|
+
* when the author cancels, and then nothing is saved. Without it a new figure is saved under a temporary type.
|
|
237
|
+
*/
|
|
238
|
+
@property({ attribute: false }) firstSave?: (name: string) => Promise<FirstSave | undefined>
|
|
229
239
|
@state() private selected = ''
|
|
230
240
|
@state() private error = ''
|
|
231
241
|
@state() private tab: SideTab = 'properties'
|
|
@@ -262,14 +272,26 @@ export class FigureV3Editor extends LitElement {
|
|
|
262
272
|
/** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
|
|
263
273
|
@state() private live: LiveValues = NOTHING_LIVE
|
|
264
274
|
private lastContext: string | null = null
|
|
265
|
-
/** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure. */
|
|
275
|
+
/** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
|
|
266
276
|
private onKey = (e: KeyboardEvent) => {
|
|
267
277
|
const view = this.session?.read()
|
|
268
278
|
if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
|
|
269
|
-
const
|
|
279
|
+
const canvas = this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')
|
|
280
|
+
const path = e.composedPath()
|
|
281
|
+
const action = keyActionOf(
|
|
282
|
+
{ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: path[0] as HTMLElement, inScene: !!canvas && path.includes(canvas) },
|
|
283
|
+
this.selected
|
|
284
|
+
)
|
|
270
285
|
if (!action) return
|
|
271
286
|
e.preventDefault()
|
|
272
|
-
|
|
287
|
+
/*
|
|
288
|
+
The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
|
|
289
|
+
board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
|
|
290
|
+
converted part wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
|
|
291
|
+
*/
|
|
292
|
+
e.stopPropagation()
|
|
293
|
+
if (action === 'translate' || action === 'rotate' || action === 'scale') canvas?.gizmo(action)
|
|
294
|
+
else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
|
|
273
295
|
else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
|
|
274
296
|
else if (action === 'duplicate') this.duplicatePart(this.selected)
|
|
275
297
|
else if (action === 'remove') this.removePart(this.selected)
|
|
@@ -277,10 +299,10 @@ export class FigureV3Editor extends LitElement {
|
|
|
277
299
|
}
|
|
278
300
|
connectedCallback() {
|
|
279
301
|
super.connectedCallback()
|
|
280
|
-
window.addEventListener('keydown', this.onKey)
|
|
302
|
+
window.addEventListener('keydown', this.onKey, true)
|
|
281
303
|
}
|
|
282
304
|
disconnectedCallback() {
|
|
283
|
-
window.removeEventListener('keydown', this.onKey)
|
|
305
|
+
window.removeEventListener('keydown', this.onKey, true)
|
|
284
306
|
super.disconnectedCallback()
|
|
285
307
|
}
|
|
286
308
|
protected willUpdate(_changed: PropertyValues) {
|
|
@@ -299,7 +321,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
299
321
|
this.error = ''
|
|
300
322
|
this.changed()
|
|
301
323
|
} catch (error) {
|
|
302
|
-
this.error = error instanceof Error ? error.message : String(error)
|
|
324
|
+
this.error = sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
|
|
303
325
|
} finally {
|
|
304
326
|
/* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
|
|
305
327
|
this.live = NOTHING_LIVE
|
|
@@ -307,7 +329,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
307
329
|
}
|
|
308
330
|
/** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
|
|
309
331
|
private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
|
|
310
|
-
const parts = (
|
|
332
|
+
const parts = (asset ? liveOverSession(asset as V3Asset, this.currentInputs(), this.live) : null) ?? drawableV3Parts(geometry ?? [])
|
|
311
333
|
return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
|
|
312
334
|
}
|
|
313
335
|
/**
|
|
@@ -317,11 +339,20 @@ export class FigureV3Editor extends LitElement {
|
|
|
317
339
|
private fanOf(asset: V3Asset | undefined) {
|
|
318
340
|
if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
|
|
319
341
|
try {
|
|
320
|
-
return jointFanOf(asset, this.selected, this.live.values)
|
|
342
|
+
return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })
|
|
321
343
|
} catch {
|
|
322
344
|
return null
|
|
323
345
|
}
|
|
324
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Every input as the session holds it now -- the pose the author has set with the controls, and design values. The
|
|
349
|
+
* asset's source carries only the saved defaults, so drawing a value in hand over it put every other joint back to its
|
|
350
|
+
* saved angle while one slider moved, and the arm jumped when released (user, 2026-09-24).
|
|
351
|
+
*/
|
|
352
|
+
private currentInputs(): Record<string, number> {
|
|
353
|
+
const view = this.session?.read()
|
|
354
|
+
return view ? Object.fromEntries(view.fields.map(field => [field.id, field.value as number])) : {}
|
|
355
|
+
}
|
|
325
356
|
/** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
|
|
326
357
|
private hold(values: Record<string, number>, edit?: V3PartEdit) {
|
|
327
358
|
this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
|
|
@@ -338,9 +369,20 @@ export class FigureV3Editor extends LitElement {
|
|
|
338
369
|
not from the one on screen, which stands wherever the author left it turned. A picture that cannot
|
|
339
370
|
be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
|
|
340
371
|
*/
|
|
372
|
+
let type: string | undefined
|
|
373
|
+
if (!this.session.read().id && this.firstSave) {
|
|
374
|
+
const answer = await this.firstSave(this.session.read().name ?? '')
|
|
375
|
+
if (!answer) return
|
|
376
|
+
/* The name is the figure's own, in the asset: one step of the history, like typing it in the properties. */
|
|
377
|
+
if (answer.name !== this.session.read().name) {
|
|
378
|
+
this.session.command({ kind: 'rename', name: answer.name })
|
|
379
|
+
this.changed()
|
|
380
|
+
}
|
|
381
|
+
type = answer.type
|
|
382
|
+
}
|
|
341
383
|
const view = this.session.read()
|
|
342
384
|
const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {})
|
|
343
|
-
const promise = this.session.save({ thumbnail })
|
|
385
|
+
const promise = this.session.save({ thumbnail, type })
|
|
344
386
|
this.changed()
|
|
345
387
|
await promise
|
|
346
388
|
this.error = ''
|
|
@@ -411,12 +453,37 @@ export class FigureV3Editor extends LitElement {
|
|
|
411
453
|
/** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
|
|
412
454
|
private transformed(id: string, result: GizmoResult) {
|
|
413
455
|
const view = this.session!.read()
|
|
456
|
+
/* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
|
|
457
|
+
const asset = JSON.parse(this.session!.source()) as V3Asset
|
|
458
|
+
const drawn = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
|
|
459
|
+
const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
|
|
460
|
+
if (drag) {
|
|
461
|
+
if (drag.kind === 'value') this.run(() => this.session!.apply([{ kind: 'set-state', input: drag.input, unit: drag.unit, value: drag.value }]))
|
|
462
|
+
else if (drag.kind === 'joint') this.run(() => this.session!.command({ kind: 'set-joint', joint: drag.joint }))
|
|
463
|
+
else {
|
|
464
|
+
this.error = i18next.t(`figure.text.gizmo-joint-${drag.reason}`, { joint: drag.joint, leader: drag.leader ?? '', axis: drag.axis ?? '' })
|
|
465
|
+
this.requestUpdate()
|
|
466
|
+
}
|
|
467
|
+
return
|
|
468
|
+
}
|
|
414
469
|
const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
|
|
415
|
-
|
|
470
|
+
/*
|
|
471
|
+
A part a joint places, scaled: only its measures change. The shift of its centre that an anchored scale makes is
|
|
472
|
+
the joint's to give -- the face on the joint stays there by itself (user, 2026-09-25: 「'R'모드에서 크기가 변하지 않는다」;
|
|
473
|
+
the shift had been taken for a move of the joint, and the size was dropped).
|
|
474
|
+
*/
|
|
475
|
+
const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id))
|
|
476
|
+
const changes = gizmoChanges(id, this.reachOf(id), jointed && result.scale ? { scale: result.scale } : result, sizes)
|
|
416
477
|
if (changes.length) {
|
|
417
478
|
this.run(() => this.session!.apply(changes))
|
|
418
479
|
return
|
|
419
480
|
}
|
|
481
|
+
if (jointed && result.scale) {
|
|
482
|
+
/* Only measures that follow another were pulled: the relation says their size, and it is not broken here. */
|
|
483
|
+
const axes = (['x', 'y', 'z'] as const).filter((_, i) => Math.abs(result.scale![i]! - 1) > 1e-4)
|
|
484
|
+
this.error = i18next.t('figure.text.gizmo-size-follows', { part: partLabelOf(id), axis: axes.join(', ').toUpperCase() })
|
|
485
|
+
return this.requestUpdate()
|
|
486
|
+
}
|
|
420
487
|
/*
|
|
421
488
|
A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
|
|
422
489
|
its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
|
|
@@ -424,14 +491,19 @@ export class FigureV3Editor extends LitElement {
|
|
|
424
491
|
*/
|
|
425
492
|
const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
|
|
426
493
|
if (!body) return this.requestUpdate()
|
|
427
|
-
if (result.scale && !result.position && !result.rotation) {
|
|
428
|
-
this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) })
|
|
429
|
-
return this.requestUpdate()
|
|
430
|
-
}
|
|
431
494
|
const { by, turn } = nudgeOf(body, result)
|
|
432
|
-
|
|
495
|
+
/* A size worked out from the figure's: the drag's factor beside its rule, so it still follows (V2 scaled any part). */
|
|
496
|
+
const grow: Partial<Record<'x' | 'y' | 'z', number>> = {}
|
|
497
|
+
if (result.scale) (['x', 'y', 'z'] as const).forEach((axis, i) => Math.abs(result.scale![i]! - 1) > 1e-6 && (grow[axis] = Math.round(result.scale![i]! * 1e6) / 1e6))
|
|
498
|
+
if (!Object.keys(by).length && !Object.keys(turn).length && !Object.keys(grow).length) return this.requestUpdate()
|
|
433
499
|
try {
|
|
434
|
-
this.session!.command({
|
|
500
|
+
this.session!.command({
|
|
501
|
+
kind: 'nudge-part',
|
|
502
|
+
part: id,
|
|
503
|
+
...(Object.keys(by).length ? { by } : {}),
|
|
504
|
+
...(Object.keys(turn).length ? { turn } : {}),
|
|
505
|
+
...(Object.keys(grow).length ? { grow } : {})
|
|
506
|
+
})
|
|
435
507
|
this.error = ''
|
|
436
508
|
this.changed()
|
|
437
509
|
} catch (error) {
|
|
@@ -593,9 +665,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
593
665
|
? i18next.t('figure.text.follow-would-loop')
|
|
594
666
|
: code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
|
|
595
667
|
? i18next.t('figure.text.fasten-would-loop')
|
|
596
|
-
: error instanceof Error
|
|
597
|
-
? error.message
|
|
598
|
-
: String(error)
|
|
668
|
+
: sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
|
|
599
669
|
}
|
|
600
670
|
}
|
|
601
671
|
private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isPlaceholderType, TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The first save of a new figure asks for its type and name, as V2's page did. The type is the name a board places
|
|
5
|
+
* the figure by; it freezes when the figure is released (ADR-0063 decision 4). The V3 modeller saved a new figure
|
|
6
|
+
* under a temporary machine name instead and asked nothing -- the author had to find the field afterwards
|
|
7
|
+
* (user, 2026-09-25: V2 features are not to go missing).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A type as typed, made into the shape a type has: letters, digits and underscores, upper case. */
|
|
11
|
+
export function typeOf(raw: string): string {
|
|
12
|
+
return raw.trim().replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether this name could be released, by the format's own rules (`type-not-identifier`, `type-is-placeholder`).
|
|
17
|
+
* Saving under it is not blocked -- it can still change before release -- but the author hears it now.
|
|
18
|
+
*/
|
|
19
|
+
export function releasableType(type: string): boolean {
|
|
20
|
+
return !isPlaceholderType(type) && TYPE_PATTERN.test(type) && type.length >= TYPE_LENGTH.min && type.length <= TYPE_LENGTH.max
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Why the first save cannot go on with this type, as an i18n key, or null when it can. */
|
|
24
|
+
export function firstSaveRefusalOf(type: string, taken: readonly string[]): { key: string; type?: string } | null {
|
|
25
|
+
if (!type) return { key: 'figure.text.enter-a-type-name' }
|
|
26
|
+
if (taken.includes(type)) return { key: 'figure.text.type-name-already-taken', type }
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the first save asks for. */
|
|
31
|
+
export interface FirstSave {
|
|
32
|
+
type: string
|
|
33
|
+
name: string
|
|
34
|
+
}
|
|
@@ -17,6 +17,16 @@ export interface GizmoReach {
|
|
|
17
17
|
scale: boolean
|
|
18
18
|
/** The inputs sizing the part along X, Y and Z, when it can be sized. */
|
|
19
19
|
sizes?: [string, string, string]
|
|
20
|
+
/**
|
|
21
|
+
* Per axis, the part's own measure, or null where the measure follows another (a square section's depth following
|
|
22
|
+
* its width): a scale drag writes the own ones and leaves a followed one to what it follows.
|
|
23
|
+
*/
|
|
24
|
+
ownSizes?: (string | null)[]
|
|
25
|
+
/**
|
|
26
|
+
* A round part keeps its section round, as V2 kept it (`keepRound`): the radii across (X and Z) take the one factor
|
|
27
|
+
* the drag gave most; the length along Y takes its own. A sphere's three radii take one factor together.
|
|
28
|
+
*/
|
|
29
|
+
round?: { across: string[]; along: string[] }
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/** Shapes whose first three arguments are the extent along X, Y and Z. */
|
|
@@ -26,10 +36,11 @@ interface Node {
|
|
|
26
36
|
id: string
|
|
27
37
|
op: string
|
|
28
38
|
args: string[]
|
|
39
|
+
outputs?: Record<string, string>
|
|
29
40
|
params?: Record<string, unknown>
|
|
30
41
|
}
|
|
31
42
|
interface AssetLike {
|
|
32
|
-
document: { model: { nodes: Node[]; inputs: Array<{ id: string; role?: string }> } }
|
|
43
|
+
document: { model: { nodes: Node[]; inputs: Array<{ id: string; role?: string }>; constants?: Array<{ id: string; value: number }> } }
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
const AXES = ['x', 'y', 'z'] as const
|
|
@@ -53,14 +64,39 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
|
|
|
53
64
|
v3AttachmentsOf(asset as unknown as V3Asset)[part] === null
|
|
54
65
|
const posed = direct || reseated
|
|
55
66
|
const shape = nodes.find(node => node.id === `${part}.shape`)
|
|
67
|
+
const mine = (ids: string[]) => ids.every(id => id.startsWith(`${part}.`) && design.has(id))
|
|
68
|
+
const round =
|
|
69
|
+
shape?.op === 'cylinder-shape@1' && mine(shape.args.slice(0, 2))
|
|
70
|
+
? { across: [shape.args[0]!], along: [shape.args[1]!] }
|
|
71
|
+
: shape?.op === 'frustum-shape@1' && mine(shape.args.slice(0, 3))
|
|
72
|
+
? { across: shape.args.slice(0, 2), along: [shape.args[2]!] }
|
|
73
|
+
: shape?.op === 'sphere-shape@1' && mine(shape.args.slice(0, 3))
|
|
74
|
+
? { across: shape.args.slice(0, 3), along: [] }
|
|
75
|
+
: undefined
|
|
56
76
|
const extent = shape && BOXES.has(shape.op) ? shape.args.slice(0, 3) : []
|
|
57
77
|
const sizes =
|
|
58
78
|
extent.length === 3 && extent.every(id => id.startsWith(`${part}.`) && design.has(id)) ? (extent as [string, string, string]) : undefined
|
|
79
|
+
/*
|
|
80
|
+
The part's own measure, also when another reads it: a measure some other part follows is read through a node of its
|
|
81
|
+
own times 1 (`link-dimension`), and it is still the author's number -- the upper arm's width, which the forearm
|
|
82
|
+
follows, is the upper arm's to drag.
|
|
83
|
+
*/
|
|
84
|
+
const constants = asset.document.model.constants ?? []
|
|
85
|
+
const ownMeasure = (id: string): string | null => {
|
|
86
|
+
if (id.startsWith(`${part}.`) && design.has(id)) return id
|
|
87
|
+
const writer = nodes.find(node => Object.values(node.outputs ?? {}).includes(id))
|
|
88
|
+
const one = writer?.op === 'mul@1' && writer.args.length === 2 && constants.find(c => c.id === writer.args[1])?.value === 1
|
|
89
|
+
return one && writer!.args[0]!.startsWith(`${part}.`) && design.has(writer!.args[0]!) ? writer!.args[0]! : null
|
|
90
|
+
}
|
|
91
|
+
const ownSizes = extent.length === 3 && !sizes ? extent.map(ownMeasure) : undefined
|
|
92
|
+
const someOwn = !!ownSizes?.some(Boolean)
|
|
59
93
|
return {
|
|
60
94
|
translate: posed && allDesign(own('position')) && own('position').every((id, i) => pose!.args[i] === id),
|
|
61
95
|
rotate: posed && allDesign(own('rotation')) && own('rotation').every((id, i) => pose!.args[3 + i] === id),
|
|
62
|
-
scale: !!sizes,
|
|
63
|
-
...(sizes ? { sizes } : {})
|
|
96
|
+
scale: !!sizes || !!round || someOwn,
|
|
97
|
+
...(sizes ? { sizes } : {}),
|
|
98
|
+
...(someOwn ? { ownSizes } : {}),
|
|
99
|
+
...(round ? { round } : {})
|
|
64
100
|
}
|
|
65
101
|
}
|
|
66
102
|
|
|
@@ -87,5 +123,18 @@ export function gizmoChanges(part: string, reach: GizmoReach, result: GizmoResul
|
|
|
87
123
|
reach.sizes.forEach((id, i) => {
|
|
88
124
|
if (sizes[id] !== undefined) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * result.scale![i]!)))
|
|
89
125
|
})
|
|
126
|
+
if (result.scale && reach.scale && reach.ownSizes)
|
|
127
|
+
reach.ownSizes.forEach((id, i) => {
|
|
128
|
+
if (id && sizes[id] !== undefined && Math.abs(result.scale![i]! - 1) > 1e-4) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * result.scale![i]!)))
|
|
129
|
+
})
|
|
130
|
+
if (result.scale && reach.scale && reach.round) {
|
|
131
|
+
const [sx, sy, sz] = result.scale
|
|
132
|
+
/* The factor the drag moved most: across for a cylinder (X or Z), any axis for a sphere. */
|
|
133
|
+
const most = (factors: number[]) => factors.reduce((a, b) => (Math.abs(b - 1) > Math.abs(a - 1) ? b : a), 1)
|
|
134
|
+
const sphere = reach.round.along.length === 0
|
|
135
|
+
const across = sphere ? most([sx!, sy!, sz!]) : most([sx!, sz!])
|
|
136
|
+
for (const id of reach.round.across) if (sizes[id] !== undefined) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * across)))
|
|
137
|
+
for (const id of reach.round.along) if (sizes[id] !== undefined) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * sy!)))
|
|
138
|
+
}
|
|
90
139
|
return changes
|
|
91
140
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
import { compileV3Asset } from '@hatiolab/figure-model'
|
|
3
|
+
import type { V3Asset, V3Joint } from '@hatiolab/figure-model'
|
|
4
|
+
|
|
5
|
+
import type { GizmoResult } from './v3-gizmo.js'
|
|
6
|
+
import type { V3RenderPart } from './v3-renderer.js'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* A gizmo drag on a part that hangs from a joint edits the joint, not the part (ADR-0093; r3 §3: the modeller changes
|
|
10
|
+
* the inputs of the relation, and never breaks it because a computed place was moved):
|
|
11
|
+
*
|
|
12
|
+
* - turning a part on a revolute or continuous joint sets the joint's value -- the turn about the joint's axis;
|
|
13
|
+
* - sliding a part on a prismatic joint along its axis sets its value;
|
|
14
|
+
* - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it.
|
|
15
|
+
*
|
|
16
|
+
* What would break a relation is refused and said: an origin that follows another part's size, a joint that mimics
|
|
17
|
+
* another, a turn on a joint that does not turn.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type JointDrag =
|
|
21
|
+
| { kind: 'value'; input: string; unit: 'deg' | 'mm' | 'ratio'; value: number }
|
|
22
|
+
| { kind: 'joint'; joint: V3Joint }
|
|
23
|
+
| { kind: 'refused'; reason: 'mimic' | 'fixed-turn' | 'origin-follows' | 'turned-slide' | 'off-axis'; joint: string; leader?: string; axis?: string }
|
|
24
|
+
|
|
25
|
+
type Axis = 'x' | 'y' | 'z'
|
|
26
|
+
const AXES: Axis[] = ['x', 'y', 'z']
|
|
27
|
+
/** The axis each face faces along, and which way (as figure-model's `V3_FACES`). */
|
|
28
|
+
const FACE_AXIS: Record<string, Axis> = { left: 'x', right: 'x', bottom: 'y', top: 'y', back: 'z', front: 'z' }
|
|
29
|
+
const FACE_SIGN: Record<string, number> = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 }
|
|
30
|
+
|
|
31
|
+
const matrixOf = (r: number[][]) => new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1)
|
|
32
|
+
const eulerMatrix = (deg: number[]) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), 'XYZ'))
|
|
33
|
+
const unit = (axis: string) => new THREE.Vector3(/x/i.test(axis) ? 1 : 0, /y/i.test(axis) ? 1 : 0, /z/i.test(axis) ? 1 : 0)
|
|
34
|
+
const round = (v: number, step: number) => Number((Math.round(v / step) * step).toFixed(Math.max(0, -Math.floor(Math.log10(step)))))
|
|
35
|
+
|
|
36
|
+
/** The part's own quarter turn in the joint frame, as the record says it. */
|
|
37
|
+
const turnMatrix = (j: V3Joint) => eulerMatrix(AXES.map(a => j.origin?.turn?.[a] ?? 0))
|
|
38
|
+
|
|
39
|
+
/** The joint frame's rotation in the figure: the part's rotation now, less its own turn and less the joint's. */
|
|
40
|
+
function jointFrameRotation(j: V3Joint, part: V3RenderPart, angle: number): THREE.Matrix4 {
|
|
41
|
+
const now = matrixOf(part.pose.r as number[][])
|
|
42
|
+
const own = turnMatrix(j)
|
|
43
|
+
const turn = j.type === 'revolute' ? new THREE.Matrix4().makeRotationAxis(unit(j.axis!), THREE.MathUtils.degToRad(angle)) : new THREE.Matrix4()
|
|
44
|
+
return now.multiply(own.invert()).multiply(turn.invert())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function jointDragOf(asset: V3Asset, id: string, part: V3RenderPart, result: GizmoResult, values: Record<string, number>): JointDrag | null {
|
|
48
|
+
const j = asset.joints?.find(joint => joint.body1.part === id)
|
|
49
|
+
/* A size is the part's own, not the joint's: a scale drag goes to the part's measures (the editor drops the shift). */
|
|
50
|
+
if (!j || result.scale || (!result.rotation && !result.position)) return null
|
|
51
|
+
const stateId = j.state?.id ?? j.id
|
|
52
|
+
const now = values[stateId] ?? asset.stateDefaults[stateId] ?? 0
|
|
53
|
+
const turns = j.type === 'revolute'
|
|
54
|
+
/* A revolute joint without limits turns without end (USD). */
|
|
55
|
+
const endless = turns && j.lowerLimit === undefined && j.upperLimit === undefined
|
|
56
|
+
|
|
57
|
+
if (result.rotation) {
|
|
58
|
+
if (!turns) return { kind: 'refused', reason: j.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
|
|
59
|
+
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
|
|
60
|
+
/* The turn about the joint's axis (the twist of the change): the only turn the joint can make. */
|
|
61
|
+
const before = new THREE.Quaternion().setFromRotationMatrix(matrixOf(part.pose.r as number[][]))
|
|
62
|
+
const after = new THREE.Quaternion().setFromRotationMatrix(eulerMatrix(result.rotation))
|
|
63
|
+
const change = after.multiply(before.invert())
|
|
64
|
+
const axis = unit(j.axis!).applyMatrix4(jointFrameRotation(j, part, now)).normalize()
|
|
65
|
+
const twist = 2 * Math.atan2(change.x * axis.x + change.y * axis.y + change.z * axis.z, change.w)
|
|
66
|
+
/* Turned about another axis: the joint cannot make that turn, and nothing is written for it. */
|
|
67
|
+
const whole = 2 * Math.acos(Math.min(1, Math.abs(change.w)))
|
|
68
|
+
if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: j.axis! }
|
|
69
|
+
let value = now + THREE.MathUtils.radToDeg(twist)
|
|
70
|
+
if (endless) value = ((((value + 180) % 360) + 360) % 360) - 180
|
|
71
|
+
else value = Math.min(Math.max(value, j.lowerLimit!), j.upperLimit!)
|
|
72
|
+
return { kind: 'value', input: stateId, unit: 'deg', value: round(value, 0.1) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const moved = new THREE.Vector3(...result.position!).sub(new THREE.Vector3(...(part.pose.t as [number, number, number])))
|
|
76
|
+
if (moved.length() < 0.5) return null
|
|
77
|
+
const frame = jointFrameRotation(j, part, turns ? now : 0)
|
|
78
|
+
|
|
79
|
+
if (j.type === 'prismatic') {
|
|
80
|
+
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
|
|
81
|
+
/* How far one unit of the value moves the part along its axis, measured on the figure itself (a ratio travels a length). */
|
|
82
|
+
const along = unit(j.axis!).applyMatrix4(frame).normalize()
|
|
83
|
+
const at = (v: number) => {
|
|
84
|
+
const g = compileV3Asset(asset).evaluate({ ...values, [stateId]: v }).geometry.find(g => g.id === id && 'pose' in g) as { pose: { t: number[] } } | undefined
|
|
85
|
+
return g ? new THREE.Vector3(...(g.pose.t as [number, number, number])).dot(along) : 0
|
|
86
|
+
}
|
|
87
|
+
const step = j.travel ? 0.01 : 1
|
|
88
|
+
const rate = (at(now + step) - at(now)) / step
|
|
89
|
+
if (Math.abs(rate) > 1e-9 && Math.abs(moved.dot(along)) >= moved.length() * 0.7) {
|
|
90
|
+
const value = Math.min(Math.max(now + moved.dot(along) / rate, j.lowerLimit!), j.upperLimit!)
|
|
91
|
+
return { kind: 'value', input: stateId, unit: j.travel ? 'ratio' : 'mm', value: round(value, j.travel ? 0.001 : 1) }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Moving the joint's origin: the change in the frame the joint sits in. */
|
|
96
|
+
const local = moved.clone().applyMatrix4(frame.clone().invert())
|
|
97
|
+
const normal: Axis = 'plane' in j.body0 ? 'y' : FACE_AXIS[j.body0.face]!
|
|
98
|
+
/* Which way a larger gap moves the part: out of the face it meets (on the plane: up when it stands, down when it hangs). */
|
|
99
|
+
const outward = 'plane' in j.body0 ? (j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
|
|
100
|
+
const origin: NonNullable<V3Joint['origin']> = { ...(j.origin ?? {}) }
|
|
101
|
+
const along = local[normal]
|
|
102
|
+
if (Math.abs(along) >= 0.5) {
|
|
103
|
+
const gap = origin.gap ?? 0
|
|
104
|
+
if (typeof gap !== 'number') return { kind: 'refused', reason: 'origin-follows', joint: j.id }
|
|
105
|
+
origin.gap = round(gap + outward * along, 1)
|
|
106
|
+
}
|
|
107
|
+
for (const axis of AXES.filter(a => a !== normal)) {
|
|
108
|
+
const d = local[axis]
|
|
109
|
+
if (Math.abs(d) < 0.5) continue
|
|
110
|
+
const how = origin.align?.[axis] ?? 'centre'
|
|
111
|
+
const was = how === 'centre' ? 0 : typeof how === 'object' && typeof how.mm === 'number' ? how.mm : null
|
|
112
|
+
if (was === null) return { kind: 'refused', reason: 'origin-follows', joint: j.id }
|
|
113
|
+
origin.align = { ...(origin.align ?? {}), [axis]: { mm: round(was + d, 1) } }
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'joint', joint: { ...j, origin } }
|
|
116
|
+
}
|
|
@@ -14,8 +14,7 @@ import type { V3RenderPart } from './v3-renderer.js'
|
|
|
14
14
|
* the slope of the control, and a joint inside other joints is drawn where its parents put it.
|
|
15
15
|
*
|
|
16
16
|
* Two ways a part turns are read:
|
|
17
|
-
* - a
|
|
18
|
-
* seat's origin;
|
|
17
|
+
* - a joint record (ADR-0093): at the joint's origin, about its axis;
|
|
19
18
|
* - a joint the converter wrote from V2 (`<joint>.motion` · `<joint>.chain`): the part is composed on the joint's
|
|
20
19
|
* chain, the pivot is the joint's origin (`<joint>.frameOnParent`).
|
|
21
20
|
* A slide has no fan. Nothing is drawn when the turn cannot be found -- a fan in some guessed plane would point the
|
|
@@ -92,15 +91,29 @@ function turnOf(asset: V3Asset, part: string, values: Record<string, unknown>, b
|
|
|
92
91
|
return axis.every(v => typeof v === 'number') ? norm(axis as number[]) : null
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
/*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
94
|
+
/*
|
|
95
|
+
A joint record (ADR-0093): the fan stands at the joint -- the point on the parent's face the child is fastened at --
|
|
96
|
+
about the joint's axis. A part that does not turn itself shows the joint that sits on it, when there is one: the
|
|
97
|
+
elbow block shows the elbow (user, 2026-09-24: 「elbow-housing itself is a axis」).
|
|
98
|
+
*/
|
|
99
|
+
const turning = (asset.joints ?? []).filter(j => j.type === 'revolute')
|
|
100
|
+
const record = turning.find(j => j.body1.part === part) ?? (() => {
|
|
101
|
+
const on = turning.filter(j => 'part' in j.body0 && j.body0.part === part)
|
|
102
|
+
return on.length === 1 && !(asset.joints ?? []).some(j => j.body1.part === part && j.type !== 'fixed') ? on[0] : undefined
|
|
103
|
+
})()
|
|
104
|
+
if (record) {
|
|
105
|
+
const child = record.body1.part
|
|
106
|
+
const carrier = node(`${child}.world`) ? `${child}.world` : `${child}.chain`
|
|
107
|
+
const pose = valueOf(node(`${child}.pose`))
|
|
108
|
+
const motion = node(`${child}.motion`)
|
|
109
|
+
const control = controlOf(motion?.args[3])
|
|
110
|
+
if (!node(carrier) || !pose || !control) return null
|
|
111
|
+
/* In the child's own frame: the joint is where its pose puts the seat's origin, the axis turned back by its pose. */
|
|
112
|
+
const back = (v: number[]) => [0, 1, 2].map(i => pose.r[0]![i]! * v[0]! + pose.r[1]![i]! * v[1]! + pose.r[2]![i]! * v[2]!)
|
|
113
|
+
const pivot = back(pose.t.map(v => -v))
|
|
114
|
+
const axis = norm(back(AXIS_NAMES.map(a => (a.toUpperCase() === record.axis ? 1 : 0))))
|
|
115
|
+
if (!axis) return null
|
|
116
|
+
return { carrier, pivot, axis, toward: [0, 0, 0], control, turnedPart: child }
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
/*
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* Typing is never taken: a key pressed in a field belongs to the field -- Backspace in a name box deletes a letter,
|
|
6
6
|
* not the part.
|
|
7
7
|
*/
|
|
8
|
-
export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect'
|
|
8
|
+
export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | GizmoKey
|
|
9
|
+
|
|
10
|
+
/** The gizmo's modes on W, E and R, as the scene has them. */
|
|
11
|
+
export type GizmoKey = 'translate' | 'rotate' | 'scale'
|
|
12
|
+
const GIZMO_KEYS: Record<string, GizmoKey> = { w: 'translate', e: 'rotate', r: 'scale' }
|
|
9
13
|
|
|
10
14
|
export interface KeyLike {
|
|
11
15
|
key: string
|
|
@@ -15,6 +19,12 @@ export interface KeyLike {
|
|
|
15
19
|
altKey?: boolean
|
|
16
20
|
/** The element the key was pressed in -- the first of the event's composed path. */
|
|
17
21
|
target?: { tagName?: string; isContentEditable?: boolean } | null
|
|
22
|
+
/**
|
|
23
|
+
* Pressed with the canvas focused. The scene takes W, E and R there itself; they are passed on only from elsewhere
|
|
24
|
+
* -- a part picked in the parts list left the focus there, and the gizmo could not be had at all (2026-09-25).
|
|
25
|
+
* Passing a key the scene already took would toggle the gizmo off again.
|
|
26
|
+
*/
|
|
27
|
+
inScene?: boolean
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
const TYPING = new Set(['INPUT', 'SELECT', 'TEXTAREA'])
|
|
@@ -30,5 +40,6 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
|
|
|
30
40
|
if (mod && key === 'd') return 'duplicate'
|
|
31
41
|
if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
|
|
32
42
|
if (!mod && key === 'escape') return 'deselect'
|
|
43
|
+
if (!mod && !e.shiftKey && !e.inScene && GIZMO_KEYS[key]) return GIZMO_KEYS[key]
|
|
33
44
|
return null
|
|
34
45
|
}
|