@things-factory/figure-ui 10.1.17 → 10.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.17",
3
+ "version": "10.1.19",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -26,8 +26,8 @@
26
26
  "clean": "npm run clean:server && npm run clean:client"
27
27
  },
28
28
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.18",
30
- "@hatiolab/things-scene": "^10.1.12",
29
+ "@hatiolab/figure-model": "^0.1.20",
30
+ "@hatiolab/things-scene": "^10.1.14",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^10.0.0",
33
33
  "@operato/graphql": "^10.0.0",
@@ -37,8 +37,8 @@
37
37
  "@operato/shell": "^10.0.0",
38
38
  "@operato/styles": "^10.0.0",
39
39
  "@operato/utils": "^10.0.0",
40
- "@things-factory/figure-service": "^10.1.17",
40
+ "@things-factory/figure-service": "^10.1.19",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "0c8214182702162ceb5398bdcf42ef14ea672394"
43
+ "gitHead": "c43ffa0853a381f2ed841664cc285d0d5750ce3a"
44
44
  }
@@ -252,7 +252,6 @@ const animationOnly = () => ({
252
252
  animations: [
253
253
  {
254
254
  name: 'door',
255
- drive: 'loop',
256
255
  channels: [
257
256
  {
258
257
  target: 'motor',
@@ -267,6 +266,46 @@ const animationOnly = () => ({
267
266
  ]
268
267
  })
269
268
 
269
+ /**
270
+ * A candidate whose only change is a value-driven pose.
271
+ *
272
+ * The same shape as the movement-only case above, and it went wrong the same way: a field the
273
+ * change list does not know about diffs to nothing, so the take button stays disabled and the
274
+ * author can neither accept the candidate nor clear it. `parameters` arrived on 2026-09-16 and
275
+ * would have landed in that hole.
276
+ */
277
+ const parameterOnly = () => ({
278
+ ...current(),
279
+ parameters: [
280
+ {
281
+ name: 'hoist',
282
+ range: { unit: 'mm', min: 0, max: 1800 },
283
+ clip: {
284
+ channels: [
285
+ {
286
+ target: 'motor',
287
+ path: 'translation',
288
+ keys: [
289
+ { at: 0, value: { x: 0, y: 0, z: 0 } },
290
+ { at: 2.5, value: { x: 0, y: -1800, z: 0 } }
291
+ ]
292
+ }
293
+ ]
294
+ }
295
+ }
296
+ ]
297
+ })
298
+
299
+ test('a candidate that only adds a value-driven pose is something the author can act on', () => {
300
+ const changes = diffProposal(current() as any, parameterOnly() as any)
301
+
302
+ assert.deepEqual(changes.map(c => `${c.kind}:${c.key}`), ['parameters:parameters'])
303
+
304
+ const taken = applyProposal(current() as any, parameterOnly() as any, new Set(['parameters'])) as any
305
+ assert.equal(taken.parameters?.length, 1, 'the parameter has to survive being taken')
306
+ assert.equal(taken.parameters[0].range.max, 1800, 'the range travels with it -- without it no value can be given')
307
+ })
308
+
270
309
  test('a candidate that only adds movement is something the author can act on', () => {
271
310
  const changes = diffProposal(current() as any, animationOnly() as any)
272
311
 
@@ -527,6 +566,7 @@ test('구조 · 저작 가능한 모든 칸은 검토를 지난다 — 조용히
527
566
  detailLevel: ['M', 'L'],
528
567
  styleKit: ['plain', 'factory'],
529
568
  animations: [undefined, animationOnly().animations],
569
+ parameters: [undefined, parameterOnly().parameters],
530
570
  capabilities: [['Holdable'], ['Holdable', 'FlowNode']]
531
571
  }
532
572
 
@@ -234,7 +234,6 @@ const FULL: FigureSource = {
234
234
  animations: [
235
235
  {
236
236
  name: 'spin',
237
- drive: 'loop',
238
237
  channels: [
239
238
  {
240
239
  target: 'wheel',
@@ -247,6 +246,30 @@ const FULL: FigureSource = {
247
246
  }
248
247
  ]
249
248
  }
249
+ ],
250
+ /*
251
+ 값이 만드는 자세. 애니메이션과 **다른 칸이라** 따로 담는다 — 같은 이유로 여기 있다.
252
+ 한 칸이 새면 열었다 저장하는 것만으로 조용히 지워진다.
253
+ */
254
+ parameters: [
255
+ {
256
+ name: 'lift',
257
+ label: '포크 높이',
258
+ range: { unit: 'mm', min: 0, max: 1800 },
259
+ default: 0,
260
+ clip: {
261
+ channels: [
262
+ {
263
+ target: 'wheel',
264
+ path: 'translation',
265
+ keys: [
266
+ { at: 0, value: { x: 0, y: 0, z: 0 } },
267
+ { at: 2.5, value: { x: 0, y: 1800, z: 0 } }
268
+ ]
269
+ }
270
+ ]
271
+ }
272
+ }
250
273
  ]
251
274
  }
252
275
 
@@ -280,6 +303,12 @@ describe('왕복해도 같다', () => {
280
303
  expect(toFigureSource(draft, parts).animations).toEqual(FULL.animations)
281
304
  })
282
305
 
306
+ it('값이 만드는 자세도 살아남는다 — 애니메이션과 다른 칸이라 따로 샐 수 있다', () => {
307
+ const { draft, parts } = fromFigureSource(FULL)
308
+
309
+ expect(toFigureSource(draft, parts).parameters).toEqual(FULL.parameters)
310
+ })
311
+
283
312
  it('안 쓰이는 필드도 살아남는다 — 조용히 사라지면 저작자가 알 길이 없다', () => {
284
313
  const { draft, parts } = fromFigureSource(FULL)
285
314
  const panel = toFigureSource(draft, parts).parts.find(part => part.name === 'panel')!
@@ -451,55 +480,36 @@ describe('배치는 씬의 좌표 모드가 아니다', () => {
451
480
  })
452
481
  })
453
482
 
454
- describe('배치가 미리보기에서 높이가 된다', () => {
483
+ describe('미리보기가 높이를 씬에게 맡긴다', () => {
455
484
  const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
456
485
  const preview = read('../client/modeller/figure-preview.ts')
457
486
 
458
- it('상자 밑면이 배치가 말하는 높이에 온다', () => {
459
- /* `figure-preview.ts` `bottomOf` 같은 계산규칙이 하나임을 여기에 못 박는다. */
460
- const bottomOf = (placement: FigureSource['placement'], height: number, ceiling: number) =>
461
- placement === 'ceiling' ? ceiling - height : placement === 'center' ? (ceiling - height) / 2 : 0
462
-
463
- const CEILING = 4000
464
-
465
- /* 바닥 기반이면 키가 달라도 **밑면**이 한 줄이다. */
466
- assert.deepEqual([3000, 1000, 500].map(h => bottomOf('floor', h, CEILING)), [0, 0, 0])
487
+ /*
488
+ 배치 기준을 높이로 옮기는 규칙은 씬이 갖는다`FigureRealObject.effectiveZPos` 청사진의
489
+ `placement` 레이어의 `heights` 정한다(ADR-0045). 미리보기는 「보드에 놓았을 때」를
490
+ 말하는 자리이므로 보드와 같은 길로 세워야 한다.
467
491
 
468
- /* 천정 기반이면 **윗면**이 줄이다 밑면은 저마다 다르다. */
469
- assert.deepEqual(
470
- [3000, 1000, 500].map(h => bottomOf('ceiling', h, CEILING) + h),
471
- [CEILING, CEILING, CEILING]
472
- )
492
+ 화면이 규칙을 들고 있었다. 그러면 씬의 규칙이 바뀌어도 이 화면만 옛 답을 내고,
493
+ 저작자는 보드에 놓고 나서야 다르다는 것을 안다.
494
+ */
495
+ it('인스턴스에 zPos 를 적지 않는다 — 적으면 적은 값이 이긴다', () => {
496
+ const at = preview.indexOf('const components = PREVIEW_WAYS.map')
497
+ const built = preview.slice(at, preview.indexOf('const draft = {', at))
473
498
 
474
- /* 중심 기반이면 **중심**이 줄이다. */
475
- assert.deepEqual(
476
- [3000, 1000, 500].map(h => bottomOf('center', h, CEILING) + h / 2),
477
- [CEILING / 2, CEILING / 2, CEILING / 2]
478
- )
499
+ assert.doesNotMatch(built, /zPos/, '여기서 높이를 정하면 씬이 무엇을 할지가 이 화면에서만 안 보인다')
479
500
  })
480
501
 
481
- it('밑면을 씬이 받는 zPos 옮긴다 — 좌표 모드마다 다른 수다', () => {
482
- const zPosOf = (bottom: number, height: number, reference: string) =>
483
- reference === 'space' ? bottom + height / 2 : reference === 'inverted' ? bottom + height : bottom
484
-
485
- /*
486
- 씬은 부품 중심을 `zPos + geometricOffsetY` 에 놓고 그 오프셋이 모드마다 다르다
487
- (`real-object.ts`). 어느 모드에서든 중심이 같은 자리에 와야 같은 밑면을 말한 것이다.
488
- */
489
- const offset = { floor: (h: number) => h / 2, space: () => 0, inverted: (h: number) => -h / 2 }
490
- const BOTTOM = 3700
491
- const HEIGHT = 300
502
+ it('배치를 높이로 옮기는 계산을 들고 있지 않다', () => {
503
+ assert.doesNotMatch(preview, /bottomOf|zPosOf/, '규칙이 벌이면 갈린다')
504
+ })
492
505
 
493
- for (const reference of ['floor', 'space', 'inverted'] as const) {
494
- const centre = zPosOf(BOTTOM, HEIGHT, reference) + offset[reference](HEIGHT)
495
- assert.equal(centre, BOTTOM + HEIGHT / 2, `${reference}: 같은 밑면이 다른 자리에 섰다`)
496
- }
506
+ it('천장 높이를 씬에 실어 준다 — 값이 사는 자리가 거기다', () => {
507
+ assert.match(preview, /heights: \{ floor: 0, ceiling: view\.ceilingHeight \}/)
497
508
  })
498
509
 
499
510
  it('미리보기가 좌표 모드를 보기 설정에서 받는다 — 도형에서가 아니라', () => {
500
511
  assert.match(preview, /placement: view\.placement/, '보기 설정의 좌표 모드를 씬에 실어야 한다')
501
512
  assert.doesNotMatch(preview, /placement: (source|toScenePlacement)/, '도형의 배치를 씬 모드로 쓰면 안 된다')
502
- assert.match(preview, /zPos: Math\.round\(zPosOf\(/, '인스턴스 높이를 배치에서 뽑아야 한다')
503
513
  })
504
514
 
505
515
  it('미리보기가 보기 설정의 조명·환경을 따른다', () => {
@@ -28,7 +28,7 @@ import { readFileSync, readdirSync } from 'node:fs'
28
28
  import { fileURLToPath } from 'node:url'
29
29
  import { join } from 'node:path'
30
30
 
31
- import { CHANNEL_PATHS, CLIP_DRIVES, INTERPOLATIONS } from '@hatiolab/figure-model'
31
+ import { CHANNEL_PATHS, INTERPOLATIONS } from '@hatiolab/figure-model'
32
32
 
33
33
  const HERE = fileURLToPath(new URL('.', import.meta.url))
34
34
  const TRANSLATIONS = join(HERE, '../translations')
@@ -125,23 +125,22 @@ test('★ 통이 갈린 짝 — 사다리 이름과 설명', () => {
125
125
  /*
126
126
  애니메이션 탭도 열거 값을 키에 이어 붙인다. 사다리와 **같은 모양의 함정**이라 같은 못을 박는다.
127
127
 
128
- 갈린 통이 둘이다 — 구동은 이름(`label`)과 설명(`text.…-note`), 경로는 이름(`label`)과
129
- 단위 설명(`text.…-unit`). 한쪽만 넣으면 화면에 키가 그대로 뜨고, 넓은 앞머리가 위의
130
- 미사용 검사를 덮어 통과시킨다.
128
+ 경로는 이름(`label`)과 단위 설명(`text.…-unit`)으로 갈린다. 한쪽만 넣으면 화면에 키가
129
+ 그대로 뜨고, 넓은 앞머리가 위의 미사용 검사를 덮어 통과시킨다.
130
+
131
+ `CLIP_DRIVES` 는 여기 없다. 구동 갈래는 화면에서 고르는 것이 아니라 **어느 목록에
132
+ 적었는가**가 되었다 — 애니메이션이면 시각이 흐르고 파라미터면 값이 자세를 만든다
133
+ (ADR-0051 ①).
131
134
 
132
135
  열거 값을 여기 다시 적지 않고 형식에서 읽는다. 적어 두면 형식에 값을 하나 더할 때
133
136
  검사가 조용히 통과한다.
134
137
  */
135
- test('★ 열거 값마다 짝이 다 있다 — 구동 · 경로 · 보간', () => {
138
+ test('★ 열거 값마다 짝이 다 있다 — 경로 · 보간', () => {
136
139
  const missing: string[] = []
137
140
  const want = (key: string) => {
138
141
  if (!(key in KO)) missing.push(key)
139
142
  }
140
143
 
141
- for (const drive of CLIP_DRIVES) {
142
- want(`figure.label.drive-${drive}`)
143
- want(`figure.text.drive-${drive}-note`)
144
- }
145
144
  for (const path of CHANNEL_PATHS) {
146
145
  want(`figure.label.path-${path}`)
147
146
  want(`figure.text.path-${path}-unit`)
@@ -156,8 +155,6 @@ test('★ 열거 값마다 짝이 다 있다 — 구동 · 경로 · 보간', ()
156
155
  test('★ 애니메이션 탭이 그 통에서 찾는다 — 이름은 label, 설명은 text', () => {
157
156
  const source = readFileSync(join(CLIENT, 'modeller/figure-animations.ts'), 'utf-8')
158
157
 
159
- assert.ok(source.includes("'figure.label.drive-' + drive"), '구동 이름을 label 통에서 찾지 않는다')
160
- assert.ok(source.includes("'figure.text.drive-'"), '구동 설명을 text 통에서 찾지 않는다')
161
158
  assert.ok(source.includes("'figure.label.path-' + path"), '경로 이름을 label 통에서 찾지 않는다')
162
159
  assert.ok(source.includes("'figure.text.path-' + channel.path + '-unit'"), '단위 설명을 text 통에서 찾지 않는다')
163
160
  })
@@ -2,6 +2,7 @@
2
2
  "figure.button.add-channel": "add channel",
3
3
  "figure.button.add-clip": "add clip",
4
4
  "figure.button.add-key": "add key",
5
+ "figure.button.add-parameter": "add parameter",
5
6
  "figure.button.ask": "ask",
6
7
  "figure.button.cancel": "cancel",
7
8
  "figure.button.center-x": "centre X",
@@ -94,6 +95,7 @@
94
95
  "figure.label.channel-target": "part",
95
96
  "figure.label.channels": "channels",
96
97
  "figure.label.clip-name": "clip name",
98
+ "figure.label.clips": "animation · time runs",
97
99
  "figure.label.coordinate-ground": "coordinate ground",
98
100
  "figure.label.corner-round": "corner round",
99
101
  "figure.label.detail-L": "detailed",
@@ -105,8 +107,6 @@
105
107
  "figure.label.draft": "draft",
106
108
  "figure.label.draw-calls-at-100": "draw calls for 100",
107
109
  "figure.label.draw-calls-at-10k": "draw calls at 10k instances",
108
- "figure.label.drive-hold": "position",
109
- "figure.label.drive-loop": "speed",
110
110
  "figure.label.emissive": "emissive",
111
111
  "figure.label.emissive-color": "lit color",
112
112
  "figure.label.environment": "environment",
@@ -158,6 +158,10 @@
158
158
  "figure.label.palette-purpose-warning": "warning signal · an attention-needed indicator",
159
159
  "figure.label.palette-role": "color role",
160
160
  "figure.label.palette-token": "palette token",
161
+ "figure.label.parameter-default": "default",
162
+ "figure.label.parameter-name": "parameter name",
163
+ "figure.label.parameter-range": "range",
164
+ "figure.label.parameters": "parameters · a value makes the pose",
161
165
  "figure.label.part": "part",
162
166
  "figure.label.part-material": "material",
163
167
  "figure.label.part-position": "position (centre)",
@@ -242,7 +246,7 @@
242
246
  "figure.text.animated-parts-over": "Over the limit. Nothing is blocked, but many of these on one board will cost frames.",
243
247
  "figure.text.animation-empty-examples": "A turning roller · a sliding door leaf · a rising lift · a blinking beacon.",
244
248
  "figure.text.animation-empty-what-it-is": "This figure does not move yet. A clip is one set of motion across several parts — a door opening is the two leaves sliding apart, not two separate things.",
245
- "figure.text.animation-is-driven-by-a-fact": "One clip moves several parts together. On a board a fact sets its value “this door is 40% open”.",
249
+ "figure.text.animation-is-driven-by-a-fact": "Motion comes in two kinds: animation, where time runs, and parameters, where the value an instance gives is the pose.",
246
250
  "figure.text.animation-needs-a-named-part": "Add a part first. A channel names the part it moves.",
247
251
  "figure.text.assistant-proposed-a-figure": "here is a proposal — the result is on the right.",
248
252
  "figure.text.axes-not-measurable-yet": "axes that cannot be measured yet are left off the chart — {axes}. the reason is listed below.",
@@ -268,7 +272,8 @@
268
272
  "figure.text.change-removed": "a part being removed",
269
273
  "figure.text.change-styleKit": "the style kit",
270
274
  "figure.text.channel-target-is-gone": "No such part. If you renamed the part, pick it again here.",
271
- "figure.text.clip-name-is-how-it-is-driven": "An instance drives the clip by this name.",
275
+ "figure.text.clip-is-time-running": "Time runs. The value an instance gives is a speed — a turning roller works this way.",
276
+ "figure.text.clip-name-is-how-it-is-driven": "An instance gives a speed by this name.",
272
277
  "figure.text.clip-name-taken": "Another clip already has this name. Instances drive by name, so two cannot share one.",
273
278
  "figure.text.clip-needs-a-channel": "With no channel there is nothing to move.",
274
279
  "figure.text.clip-needs-a-name": "Without a name nothing can drive it.",
@@ -280,9 +285,6 @@
280
285
  "figure.text.detail-level-explained": "declares how detailed this figure is meant to be. S simple suits signs and posts, M normal suits most equipment, L detailed suits the large equipment a view is built around. the level sets the part limit, and maturity checks whether the figure lives up to it.",
281
286
  "figure.text.discard-changes-and-start-new": "there are unsaved changes. discard them and start a new figure?",
282
287
  "figure.text.does-not-block": "does not block",
283
- "figure.text.drive-explained": "How the value an instance gives is read.",
284
- "figure.text.drive-hold-note": "Time is held. The value an instance gives is a position from 0 to 1 — how far open a door is.",
285
- "figure.text.drive-loop-note": "Time runs. The value an instance gives is a speed — a turning roller works this way.",
286
288
  "figure.text.emissive-explained": "the surface lights itself. 0 means it is not a lamp. it does not spill onto its surroundings.",
287
289
  "figure.text.environment-explained": "sets what metal surfaces reflect. it does not change the shape.",
288
290
  "figure.text.expand-parts-pane": "expand the part list",
@@ -335,6 +337,10 @@
335
337
  "figure.text.on-by-default-explained": "the starting state when placed on a board. an instance switching it wins.",
336
338
  "figure.text.other-segment-count": "enter a value",
337
339
  "figure.text.palette-purpose-explained": "Choose the visual role this part plays, not an implementation color name.",
340
+ "figure.text.parameter-curve-is-the-value-axis": "The curve’s time axis is the value axis. Its start is the range minimum and its end the maximum, and that end time is how long a full move takes.",
341
+ "figure.text.parameter-is-a-value-not-a-timeline": "Nothing is played back; it simply is this far — “the hoist is 1200mm down”. An instance gives that number.",
342
+ "figure.text.parameter-name-is-how-a-value-arrives": "An instance gives a value by this name.",
343
+ "figure.text.parameter-needs-a-name": "Without a name a value has nowhere to land.",
338
344
  "figure.text.part-limit-reached": "the detail level allows no more parts — raise the level or remove a part.",
339
345
  "figure.text.part-limit-reached-short": "level {level} allows {limit} parts. Raise the level or remove a part.",
340
346
  "figure.text.path-rotation-unit": "Euler degrees — the same convention as the part's own rotation.",
@@ -349,6 +355,7 @@
349
355
  "figure.text.proposal-feedback-note": "Optional note for the next suggestion (this session only)",
350
356
  "figure.text.proposal-feedback-placeholder": "e.g. keep the silhouette, but make the port easier to read",
351
357
  "figure.text.put-on-floor": "drop it to the floor (Z=0).",
358
+ "figure.text.range-needs-room": "The maximum must be above the minimum; equal leaves no room to move.",
352
359
  "figure.text.release-needs-a-save-first": "there are unsaved changes. a release publishes what was saved, so save first.",
353
360
  "figure.text.releasing": "releasing…",
354
361
  "figure.text.renaming-a-part-breaks-bindings": "the name is a stored identifier — changing it breaks bindings on instances already placed.",
@@ -388,6 +395,7 @@
388
395
  "figure.text.two-keys-at-least": "A channel needs two keys. With one it is not motion, it is a single pose.",
389
396
  "figure.text.type-name-already-taken": "the type name {type} is already in use. pick another one — it cannot be changed later.",
390
397
  "figure.text.type-name-cannot-change": "the type name is a stored identifier and cannot be changed after creation.",
398
+ "figure.text.unit-is-a-word-not-a-rule": "A word the screen and the instance read — mm, deg, %. The format does not interpret it.",
391
399
  "figure.text.unsaved-changes": "unsaved",
392
400
  "figure.text.version-number": "v{version}",
393
401
  "figure.text.view-settings-are-not-saved": "these settings are not saved. they make the view comfortable to work in and do not travel with the asset.",
@@ -2,6 +2,7 @@
2
2
  "figure.button.add-channel": "채널 추가",
3
3
  "figure.button.add-clip": "clip 추가",
4
4
  "figure.button.add-key": "키 추가",
5
+ "figure.button.add-parameter": "파라미터 추가",
5
6
  "figure.button.ask": "요청",
6
7
  "figure.button.cancel": "취소",
7
8
  "figure.button.center-x": "X 가운데",
@@ -94,6 +95,7 @@
94
95
  "figure.label.channel-target": "부품",
95
96
  "figure.label.channels": "채널",
96
97
  "figure.label.clip-name": "clip 이름",
98
+ "figure.label.clips": "애니메이션 · 시각이 흐릅니다",
97
99
  "figure.label.coordinate-ground": "좌표 바탕",
98
100
  "figure.label.corner-round": "모서리 굴림",
99
101
  "figure.label.detail-L": "상세",
@@ -105,8 +107,6 @@
105
107
  "figure.label.draft": "초안",
106
108
  "figure.label.draw-calls-at-100": "100개 기준 draw call",
107
109
  "figure.label.draw-calls-at-10k": "1만개 기준 draw call",
108
- "figure.label.drive-hold": "위치",
109
- "figure.label.drive-loop": "배속",
110
110
  "figure.label.emissive": "자체발광",
111
111
  "figure.label.emissive-color": "켜진 색",
112
112
  "figure.label.environment": "환경",
@@ -159,6 +159,10 @@
159
159
  "figure.label.palette-purpose-warning": "주의 신호 · 확인이 필요한 상태를 알리는 표시등",
160
160
  "figure.label.palette-role": "색 역할",
161
161
  "figure.label.palette-token": "팔레트 토큰",
162
+ "figure.label.parameter-default": "기본값",
163
+ "figure.label.parameter-name": "파라미터 이름",
164
+ "figure.label.parameter-range": "범위",
165
+ "figure.label.parameters": "파라미터 · 값이 자세를 만듭니다",
162
166
  "figure.label.part": "부품",
163
167
  "figure.label.part-material": "재질",
164
168
  "figure.label.part-position": "위치 (중심)",
@@ -247,7 +251,7 @@
247
251
  "figure.text.animated-parts-over": "한도를 넘었습니다. 막지는 않지만 여럿 놓을 때 이 도형이 프레임을 많이 씁니다.",
248
252
  "figure.text.animation-empty-examples": "도는 롤러 · 미끄러지는 문짝 · 오르내리는 리프트 · 깜빡이는 표시등.",
249
253
  "figure.text.animation-empty-what-it-is": "이 도형은 아직 움직이지 않습니다. clip 은 부품 여럿을 한 벌로 움직이는 단위입니다 — 문 열림은 왼짝과 오른짝이 반대로 미끄러지는 것 하나입니다.",
250
- "figure.text.animation-is-driven-by-a-fact": "clip 하나가 부품 여럿을 함께 움직입니다. 보드에서는 사실이 값을 정합니다 「지금 40% 열려 있다」처럼.",
254
+ "figure.text.animation-is-driven-by-a-fact": "움직임은 갈래입니다. 시각이 흐르는 애니메이션과, 인스턴스가 값이 그대로 자세가 되는 파라미터입니다.",
251
255
  "figure.text.animation-needs-a-named-part": "부품이 먼저 있어야 합니다. clip 은 부품을 가리켜 움직입니다.",
252
256
  "figure.text.assistant-proposed-a-figure": "제안을 만들었습니다. 오른쪽이 제안한 결과입니다.",
253
257
  "figure.text.axes-not-measurable-yet": "아직 못 재는 축은 그림에 넣지 않았습니다 — {axes}. 이유는 아래에 있습니다.",
@@ -273,7 +277,8 @@
273
277
  "figure.text.change-removed": "삭제되는 부품",
274
278
  "figure.text.change-styleKit": "스타일 킷",
275
279
  "figure.text.channel-target-is-gone": "그런 부품이 없습니다. 부품 이름을 바꾸었다면 여기서도 다시 고르십시오.",
276
- "figure.text.clip-name-is-how-it-is-driven": "인스턴스가 이름으로 구동합니다.",
280
+ "figure.text.clip-is-time-running": "시각이 흐릅니다. 인스턴스가 주는 값은 배속입니다 — 도는 롤러가 그렇습니다.",
281
+ "figure.text.clip-name-is-how-it-is-driven": "인스턴스가 이 이름으로 배속을 줍니다.",
277
282
  "figure.text.clip-name-taken": "같은 이름의 clip 이 이미 있습니다. 인스턴스가 이름으로 구동하므로 겹치면 안 됩니다.",
278
283
  "figure.text.clip-needs-a-channel": "채널이 없으면 무엇을 움직이는지 알 수 없습니다.",
279
284
  "figure.text.clip-needs-a-name": "이름이 없으면 구동할 수 없습니다.",
@@ -285,9 +290,6 @@
285
290
  "figure.text.detail-level-explained": "이 형상을 얼마나 상세하게 만들 것인지 밝히는 값입니다. S 단순은 표지나 기둥처럼 형태가 단순한 것, M 보통은 대부분의 설비, L 상세는 화면의 주역이 되는 큰 설비에 씁니다. 밝힌 등급이 부품 수 한도를 정하고, 성숙도가 그 등급에 걸맞게 만들어졌는지 봅니다.",
286
291
  "figure.text.discard-changes-and-start-new": "저장하지 않은 변경이 있습니다. 버리고 새로 시작할까요?",
287
292
  "figure.text.does-not-block": "막지는 않는 것",
288
- "figure.text.drive-explained": "인스턴스가 주는 값을 어떻게 읽을지 정합니다.",
289
- "figure.text.drive-hold-note": "시각이 멈춰 있습니다. 인스턴스가 주는 값은 0~1 위치입니다 — 열린 정도가 그렇습니다.",
290
- "figure.text.drive-loop-note": "시각이 흐릅니다. 인스턴스가 주는 값은 배속입니다 — 도는 롤러가 그렇습니다.",
291
293
  "figure.text.emissive-explained": "이 면이 스스로 밝아집니다. 0 이면 램프가 아닙니다. 주변을 물들이지는 않습니다.",
292
294
  "figure.text.environment-explained": "금속 재질이 무엇을 비출지 정한다. 형상에는 영향이 없다.",
293
295
  "figure.text.expand-parts-pane": "부품 목록 펼치기",
@@ -341,6 +343,10 @@
341
343
  "figure.text.on-by-default-explained": "보드에 놓았을 때의 처음 상태입니다. 인스턴스가 켜고 끄면 그것이 이깁니다.",
342
344
  "figure.text.other-segment-count": "직접 입력",
343
345
  "figure.text.palette-purpose-explained": "색 이름이 아니라 이 부품이 화면에서 맡을 역할을 고릅니다.",
346
+ "figure.text.parameter-curve-is-the-value-axis": "곡선의 시각 축이 값의 구간입니다. 처음이 범위의 최소, 끝이 최대이고, 끝 시각이 곧 전 구간을 움직이는 데 걸리는 시간입니다.",
347
+ "figure.text.parameter-is-a-value-not-a-timeline": "재생되는 것이 아니라 지금 그만큼인 것입니다 — 「호이스트가 1200mm 내려와 있다」처럼. 인스턴스가 그 수를 줍니다.",
348
+ "figure.text.parameter-name-is-how-a-value-arrives": "인스턴스가 이 이름으로 값을 줍니다.",
349
+ "figure.text.parameter-needs-a-name": "이름이 없으면 값이 닿을 자리가 없습니다.",
344
350
  "figure.text.part-limit-reached": "디테일 등급이 허용하는 부품 수를 모두 사용했습니다. 등급을 올리거나 부품을 삭제하세요.",
345
351
  "figure.text.part-limit-reached-short": "{level} 등급은 부품 {limit}개까지입니다. 등급을 올리거나 부품을 삭제하세요.",
346
352
  "figure.text.part-name-cannot-be-empty": "부품 이름을 입력해 주세요.",
@@ -356,6 +362,7 @@
356
362
  "figure.text.proposal-feedback-note": "다음 제안에 반영할 메모 (현재 세션에만 사용)",
357
363
  "figure.text.proposal-feedback-placeholder": "예: 실루엣은 유지하되 포트가 더 잘 보이게",
358
364
  "figure.text.put-on-floor": "바닥(Z=0)에 붙입니다.",
365
+ "figure.text.range-needs-room": "최대가 최소보다 커야 합니다. 같으면 움직일 자리가 없습니다.",
359
366
  "figure.text.recipe-conveyor": "계속 움직임 · 입력값은 속도 또는 배속",
360
367
  "figure.text.recipe-gate": "상태 위치 · 입력값은 열림 비율 0~100%",
361
368
  "figure.text.recipe-lift": "상태 위치 · 입력값은 높이 0~100%",
@@ -399,6 +406,7 @@
399
406
  "figure.text.two-keys-at-least": "키가 둘은 있어야 합니다. 하나뿐이면 움직임이 아니라 자세 하나입니다.",
400
407
  "figure.text.type-name-already-taken": "타입 이름 {type} 은 이미 쓰이고 있습니다. 나중에 바꿀 수 없으니 다른 이름을 정해 주세요.",
401
408
  "figure.text.type-name-cannot-change": "타입 이름은 저장되는 식별자여서 만든 뒤에는 바꿀 수 없습니다.",
409
+ "figure.text.unit-is-a-word-not-a-rule": "화면과 인스턴스가 읽는 낱말입니다 — mm · deg · % 처럼. 형식은 해석하지 않습니다.",
402
410
  "figure.text.unsaved-changes": "저장 안 됨",
403
411
  "figure.text.version-number": "{version}판",
404
412
  "figure.text.view-settings-are-not-saved": "여기 설정은 저장되지 않습니다. 보기 편한 상태를 만드는 곳이고, 자산에는 딸려 가지 않습니다.",