@things-factory/figure-ui 10.1.48 → 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.
Files changed (104) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +89 -26
  5. package/client/modeller/figure-v3-parts.ts +86 -10
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/side-panel-styles.ts +8 -0
  8. package/client/modeller/v3-canvas.ts +86 -12
  9. package/client/modeller/v3-editor.ts +244 -37
  10. package/client/modeller/v3-fields.ts +15 -1
  11. package/client/modeller/v3-first-save.ts +34 -0
  12. package/client/modeller/v3-gizmo.ts +52 -3
  13. package/client/modeller/v3-joint-drag.ts +116 -0
  14. package/client/modeller/v3-joint-fan.ts +24 -11
  15. package/client/modeller/v3-keys.ts +12 -1
  16. package/client/modeller/v3-links.ts +72 -5
  17. package/client/modeller/v3-live.ts +11 -0
  18. package/client/modeller/v3-product-session.ts +4 -2
  19. package/client/modeller/v3-refusals.ts +21 -0
  20. package/client/modeller/v3-stage-model.ts +24 -0
  21. package/client/pages/figure-v3-modeller-page.ts +46 -7
  22. package/dist-client/graphql/v3-store.js +2 -1
  23. package/dist-client/graphql/v3-store.js.map +1 -1
  24. package/dist-client/modeller/figure-history-panel.js +9 -1
  25. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  26. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  27. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  28. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  29. package/dist-client/modeller/figure-v3-links.d.ts +5 -0
  30. package/dist-client/modeller/figure-v3-links.js +81 -23
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  33. package/dist-client/modeller/figure-v3-parts.js +81 -10
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.js +2 -1
  36. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  37. package/dist-client/modeller/side-panel-styles.js +8 -0
  38. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  39. package/dist-client/modeller/v3-canvas.d.ts +13 -0
  40. package/dist-client/modeller/v3-canvas.js +88 -12
  41. package/dist-client/modeller/v3-canvas.js.map +1 -1
  42. package/dist-client/modeller/v3-clips.d.ts +1 -1
  43. package/dist-client/modeller/v3-editor.d.ts +32 -1
  44. package/dist-client/modeller/v3-editor.js +245 -35
  45. package/dist-client/modeller/v3-editor.js.map +1 -1
  46. package/dist-client/modeller/v3-fields.d.ts +6 -0
  47. package/dist-client/modeller/v3-fields.js +14 -2
  48. package/dist-client/modeller/v3-fields.js.map +1 -1
  49. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  50. package/dist-client/modeller/v3-first-save.js +27 -0
  51. package/dist-client/modeller/v3-first-save.js.map +1 -0
  52. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  53. package/dist-client/modeller/v3-gizmo.js +45 -2
  54. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  55. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  56. package/dist-client/modeller/v3-joint-drag.js +97 -0
  57. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  58. package/dist-client/modeller/v3-joint-fan.js +24 -11
  59. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  60. package/dist-client/modeller/v3-keys.d.ts +9 -1
  61. package/dist-client/modeller/v3-keys.js +3 -0
  62. package/dist-client/modeller/v3-keys.js.map +1 -1
  63. package/dist-client/modeller/v3-links.d.ts +45 -2
  64. package/dist-client/modeller/v3-links.js +52 -3
  65. package/dist-client/modeller/v3-links.js.map +1 -1
  66. package/dist-client/modeller/v3-live.d.ts +6 -0
  67. package/dist-client/modeller/v3-live.js +10 -0
  68. package/dist-client/modeller/v3-live.js.map +1 -1
  69. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  70. package/dist-client/modeller/v3-product-session.js +2 -2
  71. package/dist-client/modeller/v3-product-session.js.map +1 -1
  72. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  73. package/dist-client/modeller/v3-refusals.js +21 -0
  74. package/dist-client/modeller/v3-refusals.js.map +1 -0
  75. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  76. package/dist-client/modeller/v3-stage-model.js +26 -0
  77. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  78. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  79. package/dist-client/pages/figure-v3-modeller-page.js +45 -7
  80. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +7 -5
  83. package/test/browser/build.mjs +62 -0
  84. package/test/browser/modeller-page.js +198 -0
  85. package/test/browser/modeller.check.mjs +334 -0
  86. package/test/browser/serve.mjs +43 -0
  87. package/test/joints.ts +33 -0
  88. package/test/v3-clips.test.ts +3 -2
  89. package/test/v3-controls.test.ts +3 -2
  90. package/test/v3-fields.test.ts +38 -1
  91. package/test/v3-first-save.test.ts +75 -0
  92. package/test/v3-gizmo.test.ts +40 -5
  93. package/test/v3-joint-drag.test.ts +93 -0
  94. package/test/v3-joint-fan.test.ts +16 -6
  95. package/test/v3-keys.test.ts +15 -2
  96. package/test/v3-links.test.ts +62 -14
  97. package/test/v3-live.test.ts +23 -4
  98. package/test/v3-maturity.test.ts +3 -7
  99. package/test/v3-refusals.test.ts +28 -0
  100. package/translations/en.json +43 -5
  101. package/translations/ja.json +42 -4
  102. package/translations/ko.json +42 -4
  103. package/translations/ms.json +42 -4
  104. package/translations/zh.json +42 -4
@@ -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 motion made in the modeller (`add-motion`): `<part>.motion`, the frame it moves is its seat, the pivot the
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
- /* Made in the modeller: the part's own motion turns its seat about the seat's origin. */
96
- const own = node(`${part}.motion`)
97
- if (own) {
98
- if (own.op !== 'axis-turn@1') return null
99
- const axis = axisOf(own)
100
- const control = controlOf(own.args[3])
101
- const at = valueOf(node(`${part}.pose`))
102
- if (!axis || !control || !at) return null
103
- return { carrier: own.id, pivot: [0, 0, 0], axis, toward: at.t, control, turnedPart: part }
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
  }
@@ -1,6 +1,6 @@
1
- import { compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
2
  import type { V3SizeRange } from '@hatiolab/figure-model'
3
- import type { V3Asset, V3BindingPlace, V3Face } from '@hatiolab/figure-model'
3
+ import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
5
5
  /*
6
6
  * What a V3 part is tied to — the readers behind the parts' size response, fastening and motion sections (parity rows
@@ -121,6 +121,30 @@ export function canFasten(asset: V3Asset, part: string): boolean {
121
121
  return canFollow(asset, part) || shape?.op === 'cylinder-shape@1'
122
122
  }
123
123
 
124
+ /** Where a part sits across the face it is fastened to, on one axis: centred, level with an edge, or a distance off centre. */
125
+ export type Alignment = 'centre' | 'min' | 'max' | 'mm'
126
+
127
+ /**
128
+ * The axes across the face a part is fastened to — the two a fastening does not set by itself. On the plane the figure is
129
+ * mounted on these are X and Z, and the plane has no edges, so only centred or a distance is offered there.
130
+ */
131
+ export function alignAxesOf(target: V3Face | 'plane'): { axis: Axis; edges: boolean }[] {
132
+ if (target === 'plane') return (['x', 'z'] as Axis[]).map(axis => ({ axis, edges: false }))
133
+ const along = V3_FACES[target] as Axis
134
+ return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: true }))
135
+ }
136
+
137
+ /** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
138
+ export function alignOf(target: V3Face | 'plane', how: Partial<Record<Axis, Alignment>>, mm: Partial<Record<Axis, number>>) {
139
+ const align: Partial<Record<Axis, 'min' | 'max' | { mm: number }>> = {}
140
+ for (const { axis, edges } of alignAxesOf(target)) {
141
+ const one = how[axis] ?? 'centre'
142
+ if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
143
+ else if ((one === 'min' || one === 'max') && edges) align[axis] = one
144
+ }
145
+ return Object.keys(align).length ? align : undefined
146
+ }
147
+
124
148
  /**
125
149
  * The axes on which the part's position is worked out from other values rather than being its own number — the
126
150
  * converter writes V2's anchor rules that way. `fix-position` makes one its own number where it is now.
@@ -147,16 +171,20 @@ export function positionIsShare(asset: V3Asset, part: string, axis: Axis): boole
147
171
 
148
172
  /** How a part moves, if it does: slide or turn, along which axis, driven by which control. */
149
173
  export interface Motion {
150
- kind: 'slide' | 'turn'
174
+ kind: 'slide' | 'turn' | 'continuous'
151
175
  axis: Axis | '?'
152
176
  control?: { id: string; unit?: string; min?: number; max?: number }
177
+ /** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
178
+ follows?: { joint: string; multiplier: number; offset?: number }
153
179
  }
154
180
 
155
181
  export function motionOf(asset: V3Asset, part: string): Motion | undefined {
156
182
  const m = modelOf(asset)
157
183
  const node = m.nodes.find(n => n.id === `${part}.motion`)
158
184
  if (!node) return undefined
159
- const kind = node.op === 'axis-turn@1' ? 'turn' : 'slide'
185
+ const record = asset.joints?.find(j => j.body1.part === part)
186
+ const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined
187
+ const kind = endless ? 'continuous' : node.op === 'axis-turn@1' ? 'turn' : 'slide'
160
188
  const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
161
189
  const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
162
190
  /* The quantity is the fourth argument, a state input directly or through the nodes that scale it. */
@@ -174,7 +202,46 @@ export function motionOf(asset: V3Asset, part: string): Motion | undefined {
174
202
  }
175
203
  return undefined
176
204
  }
177
- return { kind, axis, control: control(quantity) }
205
+ return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: record.mimic } : {}) }
206
+ }
207
+
208
+ /** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
209
+ export interface MotionForm {
210
+ kind: 'slide' | 'turn' | 'continuous'
211
+ axis: Axis
212
+ id: string
213
+ min: number
214
+ max: number
215
+ /** The joint this one moves with; empty for a joint with its own control. */
216
+ follow: string
217
+ multiplier: number
218
+ offset: number
219
+ }
220
+
221
+ /**
222
+ * The joints a part's joint can move with (URDF's mimic): a joint that moves the same way — a slide follows a slide, a
223
+ * turn a turn — and has a control of its own, since a follower follows the joint that is driven.
224
+ */
225
+ export function leadersFor(asset: V3Asset, part: string, kind: MotionForm['kind']): V3Joint[] {
226
+ return (asset.joints ?? []).filter(j => j.body1.part !== part && j.type !== 'fixed' && !j.mimic && (j.type === 'prismatic') === (kind === 'slide'))
227
+ }
228
+
229
+ /**
230
+ * The joint record the motion form writes for a part fastened by `joint`. A joint of its own gets its limits (none for
231
+ * a turn without end, as in USD) and a control; a follower gets the leader, the multiplier and the offset, and no
232
+ * control or limits of its own — its range is the leader's times the multiplier.
233
+ */
234
+ export function motionJoint(joint: V3Joint, m: MotionForm, part: string): V3Joint {
235
+ const moving = {
236
+ id: joint.id,
237
+ body0: joint.body0,
238
+ body1: joint.body1,
239
+ ...(joint.origin ? { origin: joint.origin } : {}),
240
+ type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
241
+ axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
242
+ }
243
+ if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
244
+ return { ...moving, ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } }
178
245
  }
179
246
 
180
247
  /** The select's value for what a dimension follows now. */
@@ -77,3 +77,14 @@ export function scrubbed(start: number, pixels: number, step: number): number {
77
77
  const digits = Math.max(0, -Math.floor(Math.log10(step)))
78
78
  return Number(value.toFixed(digits))
79
79
  }
80
+
81
+ /**
82
+ * The bodies to draw while a value is in hand, over every input as the session holds it now (`current`): the pose the
83
+ * author has already set with the other controls, and the design values. Drawing over the asset's saved defaults put
84
+ * every other joint back to its saved angle while one slider moved (user, 2026-09-24: the arm jumped on release).
85
+ */
86
+ export function liveOverSession(asset: V3Asset, current: Record<string, number>, live: LiveValues): V3RenderPart[] | null {
87
+ if (!isLive(live)) return null
88
+ return livePartsOf(asset, { ...live, values: { ...current, ...live.values } })
89
+ }
90
+
@@ -24,6 +24,8 @@ export interface V3ProductStore {
24
24
  creationId?: string
25
25
  /** The card's picture. Absent when the browser could not draw one; the source is saved either way. */
26
26
  thumbnail?: string
27
+ /** The type name a new figure is created under (the first save asks for it). Not sent for an existing one. */
28
+ type?: string
27
29
  }): Promise<V3ProductRecord>
28
30
  }
29
31
 
@@ -136,13 +138,13 @@ export function createV3ProductSession(store: V3ProductStore) {
136
138
  * nothing about drawing. A save with no picture is a save: a tab in the background cannot draw, and the
137
139
  * source matters more than the card (see `v3-thumbnail.ts`).
138
140
  */
139
- async save({ thumbnail }: { thumbnail?: string } = {}) {
141
+ async save({ thumbnail, type }: { thumbnail?: string; type?: string } = {}) {
140
142
  const control = writable(),
141
143
  requestEpoch = epoch
142
144
  const exported = workspace.export(control)
143
145
  saving = true
144
146
  try {
145
- const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail })
147
+ const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, ...(id || !type ? {} : { type }) })
146
148
  if (requestEpoch !== epoch) return read()
147
149
  if (!record.id || (id !== undefined && record.id !== id)) throw Error('저장 응답의 Figure 대상이 다릅니다.')
148
150
  if (serializeV3Asset(parseV3Asset(record.source)) !== exported.text)
@@ -0,0 +1,21 @@
1
+ /*
2
+ * A command refused because the figure could not be drawn at an end of its declared size range, in the author's words
3
+ * with the numbers and where to put it right -- not the kernel's English (user, 2026-09-24: 「what is this warning
4
+ * message means?」). Kept apart from the element so it is tested without a browser.
5
+ */
6
+
7
+ type Say = (key: string, params: Record<string, string>) => string
8
+
9
+ /** The refusal in words, or nothing for any other refusal. */
10
+ export function sizeRangeRefusalOf(error: unknown, say: Say): string | null {
11
+ if ((error as { code?: string })?.code !== 'INSTANCE_SIZE_RANGE') return null
12
+ const message = error instanceof Error ? error.message : String(error)
13
+ const found = /size\.([xyz]):\s*(below|above) ([\d.]+) mm [^(;]*(?:\(([^)]+)\))?;/.exec(message)
14
+ if (!found) return null
15
+ const [, axis, side, mm, part] = found
16
+ return say(side === 'below' ? 'figure.text.size-range-too-small' : 'figure.text.size-range-too-large', {
17
+ axis: axis!.toUpperCase(),
18
+ mm: Number(mm).toLocaleString(undefined, { maximumFractionDigits: 1 }),
19
+ part: part ?? ''
20
+ })
21
+ }
@@ -126,6 +126,30 @@ export function gizmoResultOf(part: V3RenderPart, after: Record<string, unknown>
126
126
  return result
127
127
  }
128
128
 
129
+ /**
130
+ * A drag as amounts, for a part whose place is worked out (`nudge-part`): how far its origin moved and how far each of
131
+ * its angles turned (rigid@1's order, degrees). Nothing on an axis that did not change.
132
+ */
133
+ export function nudgeOf(part: V3RenderPart, result: GizmoResult): { by: Partial<Record<'x' | 'y' | 'z', number>>; turn: Partial<Record<'x' | 'y' | 'z', number>> } {
134
+ const axes = ['x', 'y', 'z'] as const
135
+ const by: Partial<Record<'x' | 'y' | 'z', number>> = {}
136
+ const turn: Partial<Record<'x' | 'y' | 'z', number>> = {}
137
+ if (result.position) axes.forEach((axis, i) => {
138
+ const d = round(result.position![i]! - (part.pose.t[i] as number))
139
+ if (Math.abs(d) > 1e-6) by[axis] = d
140
+ })
141
+ if (result.rotation) {
142
+ const r = part.pose.r
143
+ const now = new THREE.Euler().setFromRotationMatrix(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), 'XYZ')
144
+ const was: number[] = [now.x, now.y, now.z].map(radians => (radians * 180) / Math.PI)
145
+ axes.forEach((axis, i) => {
146
+ const d = round(result.rotation![i]! - was[i]!)
147
+ if (Math.abs(d) > 1e-6) turn[axis] = d
148
+ })
149
+ }
150
+ return { by, turn }
151
+ }
152
+
129
153
  /** The keys of `next` whose values differ from `current` -- only those are set, so the scene raises no echo. */
130
154
  export function changedKeys(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> | null {
131
155
  const diff: Record<string, unknown> = {}
@@ -4,7 +4,7 @@ import { PageView } from '@operato/shell'
4
4
  import { createV3ProductSession } from '../modeller/v3-product-session.js'
5
5
  import { v3ProductStore } from '../graphql/v3-store.js'
6
6
  import { i18next } from '@operato/i18n'
7
- import { fetchFigure, updateFigure } from '../graphql/index.js'
7
+ import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
8
8
  import { occupancyFromParts } from '../modeller/v3-occupancy.js'
9
9
  import type { V3AuthoringAction } from '@hatiolab/figure-model'
10
10
  import '../modeller/v3-editor.js'
@@ -13,6 +13,10 @@ import '../modeller/figure-history-panel.js'
13
13
  import '../modeller/figure-v3-verdict.js'
14
14
  import '../modeller/figure-v3-properties.js'
15
15
  import type { V3BudgetLine } from '../modeller/v3-maturity.js'
16
+ import { OxPrompt } from '@operato/popup'
17
+ import { openPopup } from '@operato/layout'
18
+ import '../modeller/figure-v3-first-save.js'
19
+ import type { FirstSave } from '../modeller/v3-first-save.js'
16
20
 
17
21
  /**
18
22
  * The V3 product page has exactly one authoritative workspace for UI and AI input transactions.
@@ -171,7 +175,16 @@ export class FigureV3ModellerPage extends PageView {
171
175
  const id = this.session.read().id
172
176
  const wanted = input.value.trim().replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
173
177
  input.value = wanted
174
- if (!id || !wanted || wanted === this.row.type) return
178
+ /*
179
+ A figure not saved yet has no row to rename: the type is held here and the first save's question opens with it.
180
+ The field was disabled until the first save, so a type typed there went nowhere and was asked for again
181
+ (2026-09-25).
182
+ */
183
+ if (!id) {
184
+ this.row = { ...this.row, type: wanted || undefined }
185
+ return
186
+ }
187
+ if (!wanted || wanted === this.row.type) return
175
188
  try {
176
189
  const result = await updateFigure(id, { type: wanted })
177
190
  this.typeError = ''
@@ -268,8 +281,17 @@ export class FigureV3ModellerPage extends PageView {
268
281
  const view = this.session.read()
269
282
  if (view.saving) return false
270
283
  if (!view.dirty) return true
271
- // The shell invokes this while the edited model is still visible, before navigating away.
272
- this.discardApproved = window.confirm('저장하지 않은 변경을 버리고 이동하시겠습니까?')
284
+ /*
285
+ The shell asks this while the edited model is still on screen. The app's own prompt, as V2 and every operato
286
+ screen ask -- not the browser's confirm box (user, 2026-09-24).
287
+ */
288
+ this.discardApproved = !!(await OxPrompt.open({
289
+ type: 'warning',
290
+ title: i18next.t('figure.text.unsaved-changes'),
291
+ text: i18next.t('figure.text.leave-without-saving'),
292
+ confirmButton: { text: i18next.t('figure.button.leave-without-saving') },
293
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
294
+ }))
273
295
  return this.discardApproved
274
296
  }
275
297
  async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
@@ -302,13 +324,31 @@ export class FigureV3ModellerPage extends PageView {
302
324
  this.error = error instanceof Error ? error.message : String(error)
303
325
  }
304
326
  }
327
+ /** The first save's question, over the names this domain already uses (V2 asked the same). */
328
+ private askFirstSave = async (name: string): Promise<FirstSave | undefined> => {
329
+ const taken = await fetchFigureTypeNames().catch(() => [] as string[])
330
+ return openPopup<FirstSave>(html`<figure-v3-first-save .type=${this.row.type ?? ''} .name=${name} .taken=${taken}></figure-v3-first-save>`, {
331
+ backdrop: true,
332
+ size: 'small',
333
+ title: i18next.t('figure.title.save-new-figure')
334
+ }).result
335
+ }
305
336
  get context() {
306
- return { title: this.session.read().name ?? 'Figure', actions: [] }
337
+ return {
338
+ title: this.session.read().name ?? 'Figure',
339
+ actions: [],
340
+ /*
341
+ No heading line: the canvas is what this page is for, and the name is already in the properties' first section
342
+ (and the browser tab). The title stays, not emptied, because the MDI tab reads it too -- the heading is switched
343
+ off where it is drawn (`ox-page-title-bar`). V2's page did this; it was lost when that page was removed.
344
+ */
345
+ heading: false
346
+ }
307
347
  }
308
348
  render() {
309
349
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
310
350
  const view = this.session.read()
311
- return html`<figure-v3-editor .session=${this.session} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
351
+ return html`<figure-v3-editor .session=${this.session} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
312
352
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
313
353
  <figure-v3-properties
314
354
  slot="figure"
@@ -326,7 +366,6 @@ export class FigureV3ModellerPage extends PageView {
326
366
  : html`<input
327
367
  .value=${this.row.type ?? ''}
328
368
  placeholder=${i18next.t('figure.text.figure-type-placeholder')}
329
- ?disabled=${!view.id}
330
369
  @change=${this.renameType}
331
370
  />`}
332
371
  ${this.typeError ? html`<small role="alert">${this.typeError}</small>` : ''}
@@ -12,13 +12,14 @@ export const v3ProductStore = {
12
12
  async save(request) {
13
13
  const response = await client.mutate({
14
14
  mutation: gql `
15
- mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String) {
15
+ mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String) {
16
16
  saveFigureV3(
17
17
  source: $source
18
18
  id: $id
19
19
  expectedSource: $expectedSource
20
20
  creationId: $creationId
21
21
  thumbnail: $thumbnail
22
+ type: $type
22
23
  ) {
23
24
  id
24
25
  source