@things-factory/figure-ui 10.1.116 → 10.1.121

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.
Files changed (62) hide show
  1. package/client/graphql/index.ts +1 -0
  2. package/client/modeller/editor.ts +2 -2
  3. package/client/modeller/fasten-geometry.ts +1 -1
  4. package/client/modeller/figure-first-save.ts +1 -1
  5. package/client/modeller/figure-history-panel.ts +21 -0
  6. package/client/modeller/figure-links.ts +33 -11
  7. package/client/modeller/first-save.ts +6 -6
  8. package/client/modeller/joint-axis.ts +36 -0
  9. package/client/modeller/joint-drag.ts +27 -7
  10. package/client/modeller/joint-fan.ts +2 -1
  11. package/client/modeller/links.ts +43 -10
  12. package/client/pages/figure-list-page.ts +3 -1
  13. package/client/pages/figure-modeller-page.ts +5 -2
  14. package/client/types.ts +2 -0
  15. package/dist-client/graphql/index.js +1 -0
  16. package/dist-client/graphql/index.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/editor.js +2 -2
  19. package/dist-client/modeller/editor.js.map +1 -1
  20. package/dist-client/modeller/fasten-geometry.js +1 -1
  21. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  22. package/dist-client/modeller/figure-first-save.js +1 -1
  23. package/dist-client/modeller/figure-first-save.js.map +1 -1
  24. package/dist-client/modeller/figure-history-panel.d.ts +4 -0
  25. package/dist-client/modeller/figure-history-panel.js +23 -0
  26. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-links.d.ts +1 -0
  28. package/dist-client/modeller/figure-links.js +31 -12
  29. package/dist-client/modeller/figure-links.js.map +1 -1
  30. package/dist-client/modeller/first-save.d.ts +2 -2
  31. package/dist-client/modeller/first-save.js +6 -6
  32. package/dist-client/modeller/first-save.js.map +1 -1
  33. package/dist-client/modeller/joint-axis.d.ts +13 -0
  34. package/dist-client/modeller/joint-axis.js +31 -0
  35. package/dist-client/modeller/joint-axis.js.map +1 -0
  36. package/dist-client/modeller/joint-drag.js +26 -6
  37. package/dist-client/modeller/joint-drag.js.map +1 -1
  38. package/dist-client/modeller/joint-fan.js +2 -1
  39. package/dist-client/modeller/joint-fan.js.map +1 -1
  40. package/dist-client/modeller/links.d.ts +16 -1
  41. package/dist-client/modeller/links.js +39 -9
  42. package/dist-client/modeller/links.js.map +1 -1
  43. package/dist-client/pages/figure-list-page.js +2 -1
  44. package/dist-client/pages/figure-list-page.js.map +1 -1
  45. package/dist-client/pages/figure-modeller-page.js +4 -1
  46. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  47. package/dist-client/route.d.ts +1 -1
  48. package/dist-client/tsconfig.tsbuildinfo +1 -1
  49. package/dist-client/types.d.ts +2 -0
  50. package/dist-client/types.js.map +1 -1
  51. package/docs/product-cutover.md +7 -7
  52. package/package.json +6 -6
  53. package/test/command-preview.test.ts +2 -2
  54. package/test/first-save-suggest.test.ts +5 -5
  55. package/test/first-save.test.ts +15 -13
  56. package/test/joint-frames.test.ts +293 -0
  57. package/test/package-download.test.ts +16 -0
  58. package/translations/en.json +3 -2
  59. package/translations/ja.json +3 -2
  60. package/translations/ko.json +3 -2
  61. package/translations/ms.json +3 -2
  62. package/translations/zh.json +3 -2
@@ -49,6 +49,7 @@ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly
49
49
  */
50
50
  const FIGURE_LIST_FIELDS = `
51
51
  id
52
+ namespace
52
53
  type
53
54
  name
54
55
  description
@@ -46,7 +46,7 @@ import { roomRefitRowsOf } from './occupancy.js'
46
46
  import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js'
47
47
  import type { FigureRenderPart } from './renderer.js'
48
48
  import type { FigureBudgetLine } from './maturity.js'
49
- import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
49
+ import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
50
50
  import { jointFanOf } from './joint-fan.js'
51
51
  import { jointDragOf } from './joint-drag.js'
52
52
  import type { FirstSave } from './first-save.js'
@@ -1386,7 +1386,7 @@ export class FigureEditor extends LitElement {
1386
1386
  const fastened = new Map(
1387
1387
  (asset?.joints ?? []).map(j => [
1388
1388
  j.body1.part,
1389
- 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
1389
+ 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`
1390
1390
  ])
1391
1391
  )
1392
1392
  const rows = new Map<string, FigurePartRow>()
@@ -139,7 +139,7 @@ export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching
139
139
  }
140
140
 
141
141
  /** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
142
- export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || j.body1.face === 'centre'))
142
+ export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || ('face' in j.body1 && j.body1.face === 'centre')))
143
143
 
144
144
  /**
145
145
  * How the face lies on the screen: X across when it is one of the face's axes, else Z; the other down the screen -- up
@@ -14,7 +14,7 @@ import { firstSaveRefusalOf, releasableType, suggestTypeOf, typeOf, type FirstSa
14
14
  * Closes with the answer, or with nothing when cancelled -- then nothing is saved.
15
15
  */
16
16
  /** The shape of an identifier the modeller offers (`suggestTypeOf`'s random form). */
17
- const OFFERED = /^FIG_[0-9A-Z]{6}$/
17
+ const OFFERED = /^fig-[0-9a-z]{6}$/
18
18
 
19
19
  @customElement('figure-first-save')
20
20
  export class FigureFirstSave extends LitElement {
@@ -10,6 +10,7 @@ import { customElement, property, state } from 'lit/decorators.js'
10
10
  import { i18next, localize } from '@operato/i18n'
11
11
  import { ScrollbarStyles } from '@operato/styles'
12
12
 
13
+ import { getPathInfo } from '@operato/utils'
13
14
  import { fetchFigureVersions, fetchReplacedDrafts, inspectFigure, releaseFigure, revertFigureVersion, undoFigureRevert } from '../graphql/index.js'
14
15
  import type { KeptDraft } from '../graphql/index.js'
15
16
  import { afterRevertAnswer, revertOutcomeUnknown, revertRequestFor } from './revert-request.js'
@@ -194,6 +195,14 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
194
195
  cursor: pointer;
195
196
  }
196
197
 
198
+ li a[package] {
199
+ padding: 2px 8px;
200
+ color: var(--md-sys-color-on-surface);
201
+ border: 1px solid var(--md-sys-color-outline-variant);
202
+ border-radius: 6px;
203
+ text-decoration: none;
204
+ }
205
+
197
206
  li button[revert]:hover {
198
207
  color: var(--md-sys-color-on-primary-container);
199
208
  background-color: var(--md-sys-color-primary-container);
@@ -229,6 +238,8 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
229
238
  * 판정이 남아 발행 단추가 잠긴 채다.
230
239
  */
231
240
  @property({ type: String }) figureType?: string
241
+ /** The namespace of the full name: with it a released version can be downloaded as a package (ADR-0114). */
242
+ @property({ type: String }) figureNamespace?: string
232
243
 
233
244
  @state() private versions: FigureVersion[] = []
234
245
  /** Drafts reverts kept, newest first -- apart from the released versions. */
@@ -420,6 +431,13 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
420
431
  this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
421
432
  }
422
433
 
434
+ /** Where one released version downloads as a package file, or undefined before the figure has a full name. */
435
+ private packageHref(version: number): string | undefined {
436
+ if (!this.figureNamespace || !this.figureType) return undefined
437
+ const { contextPath } = getPathInfo(location.pathname)
438
+ return `${contextPath}/figure-package?refs=${encodeURIComponent(`${this.figureNamespace}/${this.figureType}@${version}`)}`
439
+ }
440
+
423
441
  private renderVersion(version: FigureVersion) {
424
442
  return html`
425
443
  <li>
@@ -431,6 +449,9 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
431
449
  <button revert ?disabled=${this.busy} @click=${() => this.revert(version.version)}>
432
450
  ${i18next.t('figure.button.revert')}
433
451
  </button>
452
+ ${this.packageHref(version.version)
453
+ ? html`<a package download href=${this.packageHref(version.version)!}>${i18next.t('figure.button.download-package')}</a>`
454
+ : nothing}
434
455
  ${version.comment ? html`<span comment>${version.comment}</span>` : nothing}
435
456
  </li>
436
457
  `
@@ -33,6 +33,8 @@ import {
33
33
  leadersFor,
34
34
  motionOf,
35
35
  motionFormOf,
36
+ motionFormRefusalOf,
37
+ endPlaceKeyOf,
36
38
  motionJoint,
37
39
  placeFacesOf,
38
40
  placeIdFor,
@@ -54,6 +56,7 @@ import './figure-fasten-drawing.js'
54
56
  import type { FastenChange } from './figure-fasten-drawing.js'
55
57
  import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
56
58
  import { HelpToggles } from './help.js'
59
+ import { jointAxisWord } from './joint-axis.js'
57
60
 
58
61
  const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
59
62
  /** The target list's entry for the plane the figure stands by. */
@@ -434,17 +437,22 @@ export class FigureLinks extends localize(i18next)(LitElement) {
434
437
  * What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
435
438
  * or take it off. Right after "follow size, attached" this is the result, not a refusal (designer 2026-09-27).
436
439
  */
437
- private renderHeld(asset: FigureAsset) {
440
+ private heldWords(asset: FigureAsset) {
438
441
  const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
439
- const face = joint ? i18next.t(`figure.label.face-${joint.body1.face}`) : ''
442
+ const face = joint ? i18next.t(endPlaceKeyOf(joint.body1)) : ''
440
443
  const gap = typeof joint?.origin?.gap === 'number' ? joint.origin.gap : 0
441
444
  const where = !joint
442
445
  ? i18next.t('figure.text.held-by-rule')
443
446
  : 'plane' in joint.body0
444
447
  ? i18next.t('figure.text.held-on-plane', { face, place: i18next.t(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
445
- : i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(`figure.label.face-${joint.body0.face}`) })
448
+ : i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(endPlaceKeyOf(joint.body0)) })
449
+ return where
450
+ }
451
+
452
+ private renderHeld(asset: FigureAsset) {
453
+ const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
446
454
  return html`<div resize-held>
447
- <p><md-icon>link</md-icon> ${where}</p>
455
+ <p><md-icon>link</md-icon> ${this.heldWords(asset)}</p>
448
456
  <button act open-fastening @click=${() => this.openFastening(asset)}>
449
457
  <md-icon>link</md-icon>${i18next.t('figure.button.open-fastening')}
450
458
  </button>
@@ -521,6 +529,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
521
529
 
522
530
  private renderFastening(asset: FigureAsset) {
523
531
  const held = heldBy(asset, this.partId)
532
+ /* Placed by an origin (ADR-0107 decision 4): attach writes faces, so the form would move it off its origin. Said, and taken off only. */
533
+ if (fasteningOf(asset, this.partId)?.atOrigin) {
534
+ this.fastenCandidate = undefined
535
+ return html`<div fastened fasten-at-origin>
536
+ <p>${this.heldWords(asset)}</p>
537
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
538
+ <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
539
+ </button>
540
+ ${this.renderDetachWords()}
541
+ </div>`
542
+ }
524
543
  const f = this.fasten
525
544
  /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
526
545
  const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
@@ -852,7 +871,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
852
871
  part: this.partId,
853
872
  against,
854
873
  kind: i18next.t('figure.label.motion-' + now.kind),
855
- axis: String(now.axis).toUpperCase()
874
+ axis: now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()
856
875
  })}
857
876
  </p>
858
877
  ${ends
@@ -860,7 +879,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
860
879
  : nothing}
861
880
  <div pair>
862
881
  <span>
863
- ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
882
+ ${i18next.t('figure.label.motion-' + now.kind)} · ${now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()}
864
883
  ${now.travel
865
884
  ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
866
885
  : nothing}
@@ -903,6 +922,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
903
922
  /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
904
923
  const leaders = leadersFor(asset, this.partId, m.kind)
905
924
  const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
925
+ const refusal = motionFormRefusalOf(m, follow)
906
926
  return html`
907
927
  <div pair>
908
928
  <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
@@ -910,9 +930,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
910
930
  <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
911
931
  <option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
912
932
  </select>
913
- <select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
914
- ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
915
- </select>
933
+ ${m.axisVector
934
+ ? html`<span motion-axis-vector>${i18next.t('figure.label.motion-axis')} ${jointAxisWord(m.axisVector)}</span>`
935
+ : html`<select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
936
+ ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
937
+ </select>`}
916
938
  </div>
917
939
  ${m.followSum
918
940
  ? html`<div form>
@@ -968,8 +990,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
968
990
  </div>`}
969
991
  <button
970
992
  act
971
- ?disabled=${this.locked || (m.followSum ? false : follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && (m.travel || m.travelSum)) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
972
- title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
993
+ ?disabled=${this.locked || !!refusal}
994
+ title=${refusal === 'range' ? i18next.t('figure.text.range-needs-room') : ''}
973
995
  @click=${() => {
974
996
  this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
975
997
  this.editingMotion = false
@@ -6,9 +6,9 @@ import { isPlaceholderType, TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-m
6
6
  * under a temporary machine name and asked nothing -- the author had to find the field afterwards (user, 2026-09-25).
7
7
  */
8
8
 
9
- /** A type as typed, made into the shape a type has: letters, digits and underscores, upper case. */
9
+ /** A type as typed, made into the shape a type has: lower-case letters and digits joined by single hyphens. */
10
10
  export function typeOf(raw: string): string {
11
- return raw.trim().replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
11
+ return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
12
12
  }
13
13
 
14
14
  /**
@@ -23,14 +23,14 @@ export function releasableType(type: string): boolean {
23
23
  * An identifier offered for a new figure, so the first save asks for a name and has a valid identifier ready (designer
24
24
  * 2026-09-27, Q-D3). A name in plain letters is used as it is when it makes a free, releasable identifier; any other
25
25
  * name -- Korean, say -- gets a valid prefix and a random part: no transliteration is guessed. Never the temporary
26
- * shape (`FIGURE_` and eight), which the release gate refuses; never one this domain already uses.
26
+ * shape (`figure-` and eight), which the release gate refuses; never one this domain already uses.
27
27
  */
28
28
  export function suggestTypeOf(name: string, taken: readonly string[], random: () => number = Math.random): string {
29
- const fromName = /^[A-Za-z][A-Za-z0-9 _-]*$/.test(name.trim()) ? typeOf(name).replace(/_+/g, '_').replace(/_$/, '') : ''
29
+ const fromName = /^[A-Za-z][A-Za-z0-9 _-]*$/.test(name.trim()) ? typeOf(name) : ''
30
30
  if (fromName && releasableType(fromName) && !taken.includes(fromName)) return fromName
31
- const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
31
+ const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'
32
32
  for (;;) {
33
- const made = `FIG_${Array.from({ length: 6 }, () => alphabet[Math.floor(random() * alphabet.length)]).join('')}`
33
+ const made = `fig-${Array.from({ length: 6 }, () => alphabet[Math.floor(random() * alphabet.length)]).join('')}`
34
34
  if (releasableType(made) && !taken.includes(made)) return made
35
35
  }
36
36
  }
@@ -0,0 +1,36 @@
1
+ /*
2
+ * A joint's axis as the record keeps it (ADR-0107 decision 1): one of USD's tokens, or any vector in the joint frame
3
+ * (URDF's `axis xyz`). One reader for every place that turns it into a direction or says it.
4
+ */
5
+
6
+ export type JointAxisVector = { x: number; y: number; z: number }
7
+
8
+ const isVector = (axis: unknown): axis is JointAxisVector =>
9
+ !!axis && typeof axis === 'object' && ['x', 'y', 'z'].every(k => typeof (axis as Record<string, unknown>)[k] === 'number' && Number.isFinite((axis as Record<string, number>)[k]))
10
+
11
+ /** The axis as a unit vector in the joint frame: a token's own axis, a vector divided by its length. Nothing for none. */
12
+ export function jointAxisUnit(axis: unknown): [number, number, number] | undefined {
13
+ if (typeof axis === 'string') {
14
+ const k = ['x', 'y', 'z'].indexOf(axis.toLowerCase())
15
+ return k < 0 ? undefined : ([0, 1, 2].map(i => (i === k ? 1 : 0)) as [number, number, number])
16
+ }
17
+ if (!isVector(axis)) return undefined
18
+ const n = Math.hypot(axis.x, axis.y, axis.z)
19
+ return n > 1e-9 ? [axis.x / n, axis.y / n, axis.z / n] : undefined
20
+ }
21
+
22
+ /** The token, lowercase, when the axis is one; nothing for a vector. */
23
+ export function jointAxisToken(axis: unknown): 'x' | 'y' | 'z' | undefined {
24
+ const k = typeof axis === 'string' ? axis.toLowerCase() : ''
25
+ return k === 'x' || k === 'y' || k === 'z' ? k : undefined
26
+ }
27
+
28
+ /** The vector, as written, when the axis is one. */
29
+ export const jointAxisVectorOf = (axis: unknown): JointAxisVector | undefined => (isVector(axis) ? { x: axis.x, y: axis.y, z: axis.z } : undefined)
30
+
31
+ /** The axis said: `X` for a token, `(1, 1, 0)` for a vector as written. */
32
+ export function jointAxisWord(axis: unknown): string {
33
+ const token = jointAxisToken(axis)
34
+ if (token) return token.toUpperCase()
35
+ return isVector(axis) ? `(${axis.x}, ${axis.y}, ${axis.z})` : '?'
36
+ }
@@ -3,6 +3,7 @@ import { compileFigureAsset, figureMimicTermsOf } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureJoint } from '@hatiolab/figure-model'
4
4
 
5
5
  import type { GizmoResult } from './gizmo.js'
6
+ import { jointAxisUnit, jointAxisWord } from './joint-axis.js'
6
7
  import type { FigureRenderPart } from './renderer.js'
7
8
 
8
9
  /*
@@ -11,7 +12,8 @@ import type { FigureRenderPart } from './renderer.js'
11
12
  *
12
13
  * - turning a part on a revolute or continuous joint sets the joint's value -- the turn about the joint's axis;
13
14
  * - 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
+ * - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it;
16
+ * at a parent's origin (ADR-0107 decision 4), its `origin.offset` in the parent's frame.
15
17
  *
16
18
  * What would break a relation is refused and said: an origin that follows another part's size, a joint that mimics
17
19
  * another, a turn on a joint that does not turn.
@@ -30,9 +32,15 @@ const FACE_SIGN: Record<string, number> = { left: -1, right: 1, bottom: -1, top:
30
32
 
31
33
  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
34
  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)
35
+ const unit = (axis: unknown) => new THREE.Vector3(...(jointAxisUnit(axis) ?? [0, 0, 0]))
34
36
  const round = (v: number, step: number) => Number((Math.round(v / step) * step).toFixed(Math.max(0, -Math.floor(Math.log10(step)))))
35
37
 
38
+ /** The joint frame's words of ADR-0107, read without the kernel's types (an older kernel has none). */
39
+ const framesOf = (j: FigureJoint) => (j.origin ?? {}) as { offset?: Partial<Record<Axis, unknown>>; rotate?: Partial<Record<Axis, number>> }
40
+ /** `origin.rotate`, URDF's rpy: Rz·Ry·Rx about the fixed axes. */
41
+ const rotateMatrix = (j: FigureJoint) => eulerMatrixOrder(AXES.map(a => framesOf(j).rotate?.[a] ?? 0), 'ZYX')
42
+ const eulerMatrixOrder = (deg: number[], order: THREE.EulerOrder) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), order))
43
+
36
44
  /** The part's own quarter turn in the joint frame, as the record says it. */
37
45
  const turnMatrix = (j: FigureJoint) => eulerMatrix(AXES.map(a => j.origin?.turn?.[a] ?? 0))
38
46
 
@@ -65,7 +73,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
65
73
  const twist = 2 * Math.atan2(change.x * axis.x + change.y * axis.y + change.z * axis.z, change.w)
66
74
  /* Turned about another axis: the joint cannot make that turn, and nothing is written for it. */
67
75
  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! }
76
+ if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: jointAxisWord(j.axis) }
69
77
  let value = now + THREE.MathUtils.radToDeg(twist)
70
78
  if (endless) value = ((((value + 180) % 360) + 360) % 360) - 180
71
79
  else value = Math.min(Math.max(value, j.lowerLimit!), j.upperLimit!)
@@ -92,12 +100,24 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
92
100
  }
93
101
  }
94
102
 
95
- /* Moving the joint's origin: the change in the frame the joint sits in. */
96
- const local = moved.clone().applyMatrix4(frame.clone().invert())
103
+ /* Moving the joint's origin: the change in the frame it is placed in, before `origin.rotate` turns the joint frame. */
104
+ const local = moved.clone().applyMatrix4(frame.clone().multiply(rotateMatrix(j).invert()).invert())
105
+ const origin: NonNullable<FigureJoint['origin']> = { ...(j.origin ?? {}) }
106
+ if (!('plane' in j.body0) && !('face' in j.body0)) {
107
+ /* At the parent's origin there is no face: the frame moves by its offset, along the parent's axes. */
108
+ const offset = { ...(framesOf(j).offset ?? {}) }
109
+ for (const axis of AXES) {
110
+ const d = local[axis]
111
+ if (Math.abs(d) < 0.5) continue
112
+ const was = offset[axis] ?? 0
113
+ if (typeof was !== 'number') return { kind: 'refused', reason: 'origin-follows', joint: j.id }
114
+ offset[axis] = round(was + d, 1)
115
+ }
116
+ return { kind: 'joint', joint: { ...j, origin: { ...origin, offset } } as FigureJoint }
117
+ }
97
118
  const normal: Axis = 'plane' in j.body0 ? 'y' : FACE_AXIS[j.body0.face]!
98
119
  /* 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<FigureJoint['origin']> = { ...(j.origin ?? {}) }
120
+ const outward = 'plane' in j.body0 ? ('face' in j.body1 && j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
101
121
  const along = local[normal]
102
122
  if (Math.abs(along) >= 0.5) {
103
123
  const gap = origin.gap ?? 0
@@ -3,6 +3,7 @@ import type { FigureAsset } from '@hatiolab/figure-model'
3
3
 
4
4
  import type { FigureRenderPart } from './renderer.js'
5
5
  import { sectionShape } from './drawn-shapes.js'
6
+ import { jointAxisUnit } from './joint-axis.js'
6
7
 
7
8
  /*
8
9
  * Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
@@ -111,7 +112,7 @@ function turnOf(asset: FigureAsset, part: string, values: Record<string, unknown
111
112
  /* 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
113
  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
114
  const pivot = back(pose.t.map(v => -v))
114
- const axis = norm(back(AXIS_NAMES.map(a => (a.toUpperCase() === record.axis ? 1 : 0))))
115
+ const axis = norm(back(jointAxisUnit(record.axis) ?? [0, 0, 0]))
115
116
  if (!axis) return null
116
117
  return { carrier, pivot, axis, toward: [0, 0, 0], control, turnedPart: child }
117
118
  }
@@ -2,6 +2,7 @@ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
+ import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
5
6
 
6
7
  /*
7
8
  * What a part is tied to — the readers behind the parts' size response, fastening and motion sections. Kept apart from the element so they can be tested without a browser.
@@ -52,6 +53,9 @@ export function heldBy(asset: FigureAsset, part: string): { part: string } | { p
52
53
  return to ? { part: to } : null
53
54
  }
54
55
 
56
+ /** The word for where a joint end sits: its face's, or the part's origin for an end placed by it (ADR-0107 decision 4). */
57
+ export const endPlaceKeyOf = (end: object) => ('face' in end ? `figure.label.face-${(end as { face: string }).face}` : 'figure.label.joint-origin')
58
+
55
59
  /** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
56
60
  export function canFollow(asset: FigureAsset, part: string): boolean {
57
61
  return followableAxes(asset, part).length > 0
@@ -231,20 +235,24 @@ export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
231
235
  /* A part that moves may be a row too: every copy on the one control, from its own place (designer 2026-09-27). */
232
236
  if (!j || j.repeat || !('part' in j.body0)) return []
233
237
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
234
- return AXES.filter(axis => axis !== FIGURE_FACES[j.body1.face])
238
+ /* Placed by an origin: no face to lay the row across, and the kernel refuses it. */
239
+ const by = j.body1
240
+ if (!('face' in j.body0) || !('face' in by)) return []
241
+ return AXES.filter(axis => axis !== FIGURE_FACES[by.face])
235
242
  }
236
243
 
237
244
  /**
238
245
  * Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
239
246
  * Nothing when it can, or when it already is a row (that row is shown where it stands).
240
247
  */
241
- export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | undefined {
248
+ export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | undefined {
242
249
  const joints = asset.joints ?? []
243
250
  const j = joints.find(o => o.body1.part === part)
244
251
  if (!j) return 'unfastened'
245
252
  if (j.repeat) return undefined
246
253
  if (!('part' in j.body0)) return 'on-plane'
247
254
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
255
+ if (!('face' in j.body0) || !('face' in j.body1)) return 'at-origin'
248
256
  return undefined
249
257
  }
250
258
 
@@ -332,12 +340,15 @@ export function fasteningOf(
332
340
  twist: number
333
341
  align: Partial<Record<Axis, Alignment>>
334
342
  alignMm: Partial<Record<Axis, number>>
343
+ /** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
344
+ atOrigin?: true
335
345
  } | null {
336
346
  const record = asset.joints?.find(j => j.body1.part === part)
337
347
  if (!record) return null
338
348
  const toPlane = 'plane' in record.body0
339
349
  const target = 'part' in record.body0 ? record.body0.part : ''
340
- const targetFace = 'part' in record.body0 ? record.body0.face : 'top'
350
+ const targetFace = 'face' in record.body0 ? record.body0.face : 'top'
351
+ const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || !('face' in record.body1))
341
352
  const twist = record.origin?.twist ?? 0
342
353
  /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
343
354
  const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
@@ -357,14 +368,15 @@ export function fasteningOf(
357
368
  }
358
369
  const gap = record.origin?.gap
359
370
  return {
360
- face: record.body1.face,
371
+ face: 'face' in record.body1 ? record.body1.face : 'centre',
361
372
  toPlane,
362
373
  target,
363
374
  targetFace,
364
375
  facing: record.origin?.facing ?? 'meet',
365
376
  gap: typeof gap === 'number' ? gap : 0,
366
377
  twist,
367
- ...kept
378
+ ...kept,
379
+ ...(atOrigin ? { atOrigin: true as const } : {})
368
380
  }
369
381
  }
370
382
 
@@ -456,6 +468,8 @@ export function positionIsShare(asset: FigureAsset, part: string, axis: Axis): b
456
468
  export interface Motion {
457
469
  kind: 'slide' | 'turn' | 'continuous'
458
470
  axis: Axis | '?'
471
+ /** The record's axis when it is a vector, as written (the graph holds it turned into the parent's frame). */
472
+ vector?: JointAxisVector
459
473
  control?: { id: string; unit?: string; min?: number; max?: number }
460
474
  /** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
461
475
  /** What it follows, as a sum of leaders each with its multiplier, plus an offset (one leader is a sum of one). */
@@ -469,7 +483,9 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
469
483
  const node = m.nodes.find(n => n.id === `${part}.motion`)
470
484
  if (!node) return undefined
471
485
  const record = asset.joints?.find(j => j.body1.part === part)
472
- const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined
486
+ /* A follower has no limits of its own: its range is its leaders', not a turn without end. */
487
+ const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined && !record.mimic
488
+ const vector = jointAxisVectorOf(record?.axis)
473
489
  const kind = endless ? 'continuous' : node.op === 'axis-turn@1' ? 'turn' : 'slide'
474
490
  const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
475
491
  const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
@@ -490,13 +506,15 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
490
506
  }
491
507
  const one = record?.travel && 'source' in record.travel ? record.travel : undefined
492
508
  const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
493
- return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
509
+ return { kind, axis, ...(vector ? { vector } : {}), control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
494
510
  }
495
511
 
496
512
  /** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
497
513
  export interface MotionForm {
498
514
  kind: 'slide' | 'turn' | 'continuous'
499
515
  axis: Axis
516
+ /** An axis that is a vector (ADR-0107): said, not chosen here, and written back unchanged. */
517
+ axisVector?: JointAxisVector
500
518
  id: string
501
519
  min: number
502
520
  max: number
@@ -531,10 +549,13 @@ export function motionFormOf(joint: FigureJoint): MotionForm {
531
549
  const travelSum = joint.travel && 'sum' in joint.travel ? joint.travel : undefined
532
550
  const terms = figureMimicTermsOf(joint.mimic)
533
551
  const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
552
+ const axisVector = jointAxisVectorOf(joint.axis)
534
553
  return {
535
554
  kind,
536
- axis: (joint.axis ?? 'X').toLowerCase() as Axis,
537
- id: joint.state?.id ?? '',
555
+ axis: jointAxisToken(joint.axis) ?? 'x',
556
+ ...(axisVector ? { axisVector } : {}),
557
+ /* A moving joint with no state record is driven by the input named after it (figure-model `stateIdOf`): keep that name, so an edit does not rename the control a board or a controller writes to. */
558
+ id: joint.state?.id ?? (joint.type !== 'fixed' && !joint.mimic ? joint.id : ''),
538
559
  min: travel || travelSum ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
539
560
  max: travel || travelSum ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
540
561
  follow: single?.joint ?? '',
@@ -569,7 +590,7 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
569
590
  /* A row keeps being a row when it is given a motion: dropped here, the row went with it (designer 2026-09-27). */
570
591
  ...(joint.repeat ? { repeat: joint.repeat } : {}),
571
592
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
572
- axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
593
+ axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJoint['axis']
573
594
  }
574
595
  if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
575
596
  if (m.followSum) return { ...moving, mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } }
@@ -581,6 +602,18 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
581
602
  return { ...moving, ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } }
582
603
  }
583
604
 
605
+ /**
606
+ * Why the motion form cannot be saved as it stands, as the kernel would refuse it: a follower's multiplier that is not
607
+ * a number or is 0, or a range whose most is not above its least. A range need not hold 0 -- the control starts at the
608
+ * nearest end (a lift that rests at 200 mm).
609
+ */
610
+ export function motionFormRefusalOf(m: MotionForm, follow: string): 'multiplier' | 'range' | undefined {
611
+ if (m.followSum) return undefined
612
+ if (follow) return Number.isFinite(m.multiplier) && m.multiplier !== 0 ? undefined : 'multiplier'
613
+ if (m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))) return undefined
614
+ return Number.isFinite(m.min) && Number.isFinite(m.max) && m.max > m.min ? undefined : 'range'
615
+ }
616
+
584
617
  /** The select's value for what a dimension follows now. */
585
618
  export function choiceOf(follow: Follow): string {
586
619
  switch (follow.of) {
@@ -317,7 +317,9 @@ export class FigureListPage extends FigureListPageBase {
317
317
  header: i18next.t('figure.label.figure-type'),
318
318
  record: {
319
319
  editable: false,
320
- renderer: (value: string) => html`<code>${value}</code>`
320
+ /* The full name a board stores: {namespace}/{type} (ADR-0114). */
321
+ renderer: (value: string, column: unknown, record: { namespace?: string }) =>
322
+ html`<code>${record?.namespace ? `${record.namespace}/${value}` : value}</code>`
321
323
  },
322
324
  filter: 'search',
323
325
  sortable: true,
@@ -28,6 +28,7 @@ import {
28
28
  provideAssistantSurface
29
29
  } from '@things-factory/ai-assistant/dist-client/index.js'
30
30
  import type { FirstSave } from '../modeller/first-save.js'
31
+ import { typeOf } from '../modeller/first-save.js'
31
32
  import { placementRefusalOf } from '../modeller/refusals.js'
32
33
  import { proposalOutcomeFrom, takeFigureOffer, takePendingNewFigure } from '../modeller/ai-proposal.js'
33
34
 
@@ -111,7 +112,7 @@ export class FigureModellerPage extends PageView {
111
112
  * `figure-modeller` 가 이 화면으로 바뀌면 저작자는 발행을 할 수 없었다. 새 도형은 공간을 선언하기 전에는 전부
112
113
  * 발행 판정에 막히는데 그 선언을 할 단추도 없었다(2026-09-24 확인). 그래서 발행 패널을 건다.
113
114
  */
114
- @state() private row: { state?: 'draft' | 'released'; version: number; type?: string; category?: string; tags?: string[] } = {
115
+ @state() private row: { state?: 'draft' | 'released'; version: number; namespace?: string; type?: string; category?: string; tags?: string[] } = {
115
116
  version: 0
116
117
  }
117
118
  @state() private typeError = ''
@@ -235,6 +236,7 @@ export class FigureModellerPage extends PageView {
235
236
  this.row = {
236
237
  state: figure.state as 'draft' | 'released',
237
238
  version: figure.version ?? 0,
239
+ namespace: figure.namespace ?? undefined,
238
240
  type: figure.type,
239
241
  category: figure.category ?? undefined,
240
242
  tags: figure.tags ?? undefined
@@ -253,7 +255,7 @@ export class FigureModellerPage extends PageView {
253
255
  private renameType = async (event: Event) => {
254
256
  const input = event.target as HTMLInputElement
255
257
  const id = this.session.read().id
256
- const wanted = input.value.trim().replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
258
+ const wanted = typeOf(input.value)
257
259
  input.value = wanted
258
260
  /*
259
261
  A figure not saved yet has no row to rename: the type is held here and the first save's question opens with it.
@@ -533,6 +535,7 @@ export class FigureModellerPage extends PageView {
533
535
  .figureState=${this.row.state}
534
536
  .figureVersion=${this.row.version}
535
537
  .figureType=${this.row.type}
538
+ .figureNamespace=${this.row.namespace}
536
539
  .dirty=${!!view.dirty}
537
540
  .saveFirst=${this.saveBeforeRevert}
538
541
  .serverSource=${() => this.session.confirmedSource()}
package/client/types.ts CHANGED
@@ -7,6 +7,8 @@ export interface Figure {
7
7
  id: string
8
8
  /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */
9
9
  type: string
10
+ /** Namespace of the full name {namespace}/{type} (ADR-0114). */
11
+ namespace?: string | null
10
12
  name: string
11
13
  description?: string
12
14
  /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */
@@ -34,6 +34,7 @@ function unwrap(response, field) {
34
34
  */
35
35
  const FIGURE_LIST_FIELDS = `
36
36
  id
37
+ namespace
37
38
  type
38
39
  name
39
40
  description