@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.
- 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 +86 -10
- package/client/modeller/figure-v3-properties.ts +2 -1
- package/client/modeller/side-panel-styles.ts +8 -0
- package/client/modeller/v3-canvas.ts +86 -12
- package/client/modeller/v3-editor.ts +244 -37
- package/client/modeller/v3-fields.ts +15 -1
- 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/modeller/v3-stage-model.ts +24 -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.d.ts +13 -0
- package/dist-client/modeller/figure-v3-parts.js +81 -10
- 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/side-panel-styles.js +8 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +13 -0
- package/dist-client/modeller/v3-canvas.js +88 -12
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +32 -1
- package/dist-client/modeller/v3-editor.js +245 -35
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +6 -0
- package/dist-client/modeller/v3-fields.js +14 -2
- package/dist-client/modeller/v3-fields.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/modeller/v3-stage-model.d.ts +8 -0
- package/dist-client/modeller/v3-stage-model.js +26 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -1
- 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 +38 -1
- package/test/v3-first-save.test.ts +75 -0
- package/test/v3-gizmo.test.ts +40 -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 +3 -7
- package/test/v3-refusals.test.ts +28 -0
- package/translations/en.json +43 -5
- package/translations/ja.json +42 -4
- package/translations/ko.json +42 -4
- package/translations/ms.json +42 -4
- 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
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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 {
|
|
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
|