@things-factory/figure-ui 10.1.51 → 10.1.53

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 (49) hide show
  1. package/client/figure-assistant.ts +20 -0
  2. package/client/index.ts +1 -0
  3. package/client/modeller/figure-v3-links.ts +56 -4
  4. package/client/modeller/v3-editor.ts +37 -5
  5. package/client/modeller/v3-links.ts +58 -10
  6. package/client/modeller/v3-refusals.ts +19 -0
  7. package/client/pages/figure-v3-modeller-page.ts +46 -16
  8. package/dist-client/figure-assistant.d.ts +1 -0
  9. package/dist-client/figure-assistant.js +19 -0
  10. package/dist-client/figure-assistant.js.map +1 -0
  11. package/dist-client/index.d.ts +1 -0
  12. package/dist-client/index.js +1 -0
  13. package/dist-client/index.js.map +1 -1
  14. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  15. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  16. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  17. package/dist-client/modeller/figure-v3-links.d.ts +3 -1
  18. package/dist-client/modeller/figure-v3-links.js +57 -4
  19. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  20. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  21. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  22. package/dist-client/modeller/v3-editor.d.ts +2 -0
  23. package/dist-client/modeller/v3-editor.js +39 -4
  24. package/dist-client/modeller/v3-editor.js.map +1 -1
  25. package/dist-client/modeller/v3-links.d.ts +34 -2
  26. package/dist-client/modeller/v3-links.js +46 -6
  27. package/dist-client/modeller/v3-links.js.map +1 -1
  28. package/dist-client/modeller/v3-refusals.d.ts +13 -0
  29. package/dist-client/modeller/v3-refusals.js +20 -0
  30. package/dist-client/modeller/v3-refusals.js.map +1 -1
  31. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -1
  32. package/dist-client/pages/figure-v3-modeller-page.js +41 -15
  33. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +6 -5
  36. package/test/browser/modeller.check.mjs +64 -0
  37. package/test/figure-assistant-surface.test.ts +52 -0
  38. package/test/v3-links.test.ts +81 -2
  39. package/test/v3-refusals.test.ts +21 -0
  40. package/translations/en.json +13 -1
  41. package/translations/ja.json +13 -1
  42. package/translations/ko.json +13 -1
  43. package/translations/ms.json +13 -1
  44. package/translations/zh.json +13 -1
  45. package/client/modeller/figure-ai-target.ts +0 -23
  46. package/dist-client/modeller/figure-ai-target.d.ts +0 -18
  47. package/dist-client/modeller/figure-ai-target.js +0 -22
  48. package/dist-client/modeller/figure-ai-target.js.map +0 -1
  49. package/test/ai-target.test.ts +0 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.51",
3
+ "version": "10.1.53",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.60",
31
- "@hatiolab/things-scene": "^10.1.37",
30
+ "@hatiolab/figure-model": "^0.1.62",
31
+ "@hatiolab/things-scene": "^10.1.39",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -39,8 +39,9 @@
39
39
  "@operato/shell": "^10.0.0",
40
40
  "@operato/styles": "^10.0.0",
41
41
  "@operato/utils": "^10.0.0",
42
- "@things-factory/figure-service": "^10.1.51",
42
+ "@things-factory/ai-assistant": "^10.1.53",
43
+ "@things-factory/figure-service": "^10.1.53",
43
44
  "three": "^0.185.1"
44
45
  },
45
- "gitHead": "973bfc7427199d78f6487f981366aa27d4a76e69"
46
+ "gitHead": "1bdb0c604614735fd5a5467aee1e56f53be6b89e"
46
47
  }
@@ -353,6 +353,70 @@ describe('the fastening form belongs to the part it is for', () => {
353
353
  })
354
354
  })
355
355
 
356
+ describe('a figure made in the modeller is given its size from its parts (user 2026-09-25)', () => {
357
+ before(async () => {
358
+ await mount('box')
359
+ })
360
+
361
+ test('with nothing picked, the figure offers to declare its size; clicked, its size fields appear', async () => {
362
+ const button = await page().evaluateHandle(() => {
363
+ const deep = (root = document) => [...root.querySelectorAll('button')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
364
+ return deep().find(b => b.textContent.includes('figure.button.declare-instance-size')) ?? null
365
+ })
366
+ assert.ok(await button.evaluate(b => !!b), 'the button is offered on a figure with no size')
367
+ const box = await button.evaluate(b => {
368
+ b.scrollIntoView({ block: 'center' })
369
+ const r = b.getBoundingClientRect()
370
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
371
+ })
372
+ const start = await state()
373
+ await click(box)
374
+ const after = await state()
375
+ assert.equal(after.error, '', 'not refused')
376
+ assert.equal(after.revision, start.revision + 1, 'one step')
377
+ assert.deepEqual(await read(() => probe.fields('size.')), { 'size.x': 400, 'size.y': 400, 'size.z': 400 }, 'the size it is authored at: its parts now')
378
+ })
379
+
380
+ test('a part follows the figure\'s width less a difference: chosen and typed, the width is the size less it', async () => {
381
+ await click(await read(() => probe.partOnPage('box')))
382
+ assert.equal((await state()).selected, 'box')
383
+ const handle = (what, label) =>
384
+ page().evaluateHandle(
385
+ (what, label) => {
386
+ const deep = (root = document) => [...root.querySelectorAll(what)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
387
+ if (what === 'select') return deep().find(s => s.previousElementSibling?.textContent?.trim() === label && [...s.options].some(o => o.value === 'instance:x')) ?? null
388
+ return deep().find(i => i.closest('label[inline-plus]')) ?? null
389
+ },
390
+ what,
391
+ label
392
+ )
393
+ const width = await handle('select', 'X')
394
+ assert.ok(await width.evaluate(s => !!s), 'the width can follow the figure size')
395
+ await width.select('instance:x')
396
+ await sleep(600)
397
+ const plus = await handle('input')
398
+ assert.ok(await plus.evaluate(i => !!i), 'a following width offers its difference')
399
+ await plus.evaluate(i => i.scrollIntoView({ block: 'center' }))
400
+ await plus.click({ clickCount: 3 })
401
+ await page().keyboard.type('-50')
402
+ await page().keyboard.press('Tab')
403
+ await sleep(600)
404
+ /* The declared sizes start at 1 mm: less 50 the box would be gone below 50, so the link is refused -- with the way on. */
405
+ assert.match((await state()).error, /figure.text.link-needs-smaller-range/, 'refused, said in words')
406
+ const narrow = await page().evaluateHandle(() => {
407
+ const deep = (root = document) => [...root.querySelectorAll('button')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
408
+ return deep().find(b => b.textContent.includes('figure.button.narrow-range-from')) ?? null
409
+ })
410
+ assert.ok(await narrow.evaluate(b => !!b), 'the same link is offered with the sizes narrowed')
411
+ await click(await narrow.evaluate(b => {
412
+ const r = b.getBoundingClientRect()
413
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
414
+ }))
415
+ assert.equal((await state()).error, '', 'taken')
416
+ assert.equal((await body('box')).dimensions.width, 350, 'the figure\'s 400 less 50')
417
+ })
418
+ })
419
+
356
420
  test('the page raised no error', () => {
357
421
  assert.deepEqual(opened.errors, [])
358
422
  })
@@ -0,0 +1,52 @@
1
+ /*
2
+ * The V3 modeller's AI - the framework dock talks about the figure on screen (design/ai/shared-dock.md §3).
3
+ *
4
+ * What this holds: ① a saved figure provides a surface anchored on its row ② the draft is read at the moment of
5
+ * sending, not when the surface was provided ③ an unsaved figure provides nothing - no conversation without a row
6
+ * ④ the page takes the surface back when it goes inactive or leaves. The page pulls in the shell and things-scene,
7
+ * so this reads its source.
8
+ */
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { test } from 'node:test'
13
+
14
+ const source = readFileSync(fileURLToPath(new URL('../client/pages/figure-v3-modeller-page.ts', import.meta.url)), 'utf8')
15
+
16
+ test('a saved figure provides a surface anchored on its row', () => {
17
+ assert.match(source, /provideAssistantSurface\(this, \{/)
18
+ assert.match(source, /subject: 'figure'/)
19
+ assert.match(source, /anchor: \(\) => \(\{ anchorType: 'figure', anchorId: id \}\)/)
20
+ })
21
+
22
+ test('the draft is read when the message is sent, as host context the figure tools read', () => {
23
+ assert.match(source, /hostContext: \(\) => \(\{[\s\S]*?figureId: id,[\s\S]*?draftSource: JSON\.parse\(this\.session\.source\(\)\),[\s\S]*?palette: Object\.keys\(activePalette\(\)\)/)
24
+ })
25
+
26
+ test('an unsaved figure provides nothing', () => {
27
+ assert.match(source, /if \(!view\.id\) \{\s*this\.withdrawAssistant\(\)/)
28
+ })
29
+
30
+ test('the surface is taken back when the page goes inactive or leaves', () => {
31
+ const withdraws = source.match(/this\.withdrawAssistant\(\)/g) ?? []
32
+ assert.ok(withdraws.length >= 3, 'unsaved, inactive and disconnected each withdraw')
33
+ assert.doesNotMatch(source, /figure-ai-context/, 'the old window-event channel is gone')
34
+ })
35
+
36
+ test('the V3 apply entry stays for the proposer that writes V3 changes', () => {
37
+ assert.match(source, /window\.addEventListener\('figure-v3-apply', this\.applyAI\)/)
38
+ })
39
+
40
+ test('a figure candidate is answered - unavailable, in words - never left without a handler', () => {
41
+ const handler = readFileSync(fileURLToPath(new URL('../client/figure-assistant.ts', import.meta.url)), 'utf8')
42
+ assert.match(handler, /registerProposalHandler\(FIGURE_CANDIDATE_TOOL,/)
43
+ assert.match(handler, /export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'/)
44
+ assert.match(handler, /verdict: 'unavailable'/)
45
+ assert.match(handler, /figure\.text\.candidate-cannot-open/)
46
+ for (const lang of ['en', 'ko', 'ja', 'zh', 'ms']) {
47
+ const words = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
48
+ assert.ok(words['figure.text.candidate-cannot-open'], `${lang} has no words for it`)
49
+ }
50
+ const index = readFileSync(fileURLToPath(new URL('../client/index.ts', import.meta.url)), 'utf8')
51
+ assert.match(index, /figure-assistant\.js/)
52
+ })
@@ -30,7 +30,7 @@ test('크기 — 제 치수 · 인스턴스 크기 · 다른 부품의 치수를
30
30
  const byInstance = applyV3Authoring(asset, { kind: 'link-dimension', part: 'plate', axis: 'x', source: { of: 'instance', axis: 'x' } })
31
31
  assert.deepEqual(followOf(byInstance, 'plate', 'x'), { of: 'instance', axis: 'x', times: 1 })
32
32
  const byPart = applyV3Authoring(byInstance, { kind: 'link-dimension', part: 'block', axis: 'x', source: { of: 'part', part: 'plate', axis: 'x' }, times: 0.5 })
33
- assert.deepEqual(followOf(byPart, 'block', 'x'), { of: 'part', part: 'plate', axis: 'x' }, '배율이 끼어도 원천을 찾는다')
33
+ assert.deepEqual(followOf(byPart, 'block', 'x'), { of: 'part', part: 'plate', axis: 'x', times: 0.5 }, '배율이 끼어도 원천을 찾고, 배율도 읽는다')
34
34
  const back = applyV3Authoring(byPart, { kind: 'unlink-dimension', part: 'block', axis: 'x', mm: 300 })
35
35
  assert.deepEqual(followOf(back, 'block', 'x'), { of: 'own' }, '떼면 다시 제 치수')
36
36
  assert.equal(hasInstanceSize(asset, 'y'), true)
@@ -182,7 +182,8 @@ test('비례 — 인스턴스 크기의 몫을 따르는 치수는 비례로 읽
182
182
  m.nodes.push({ id: 'flange.free', op: 'add@1', args: ['size.y', 'k.gaps'], outputs: { value: 'flange.free.value' } })
183
183
  m.nodes.push({ id: 'flange.dimension.y', op: 'mul@1', args: ['flange.free.value', m.constants.find(c => c.value === 1)?.id ?? 'k.one'], outputs: { value: 'flange.dimension.y.value' } })
184
184
  if (!m.constants.some(c => c.value === 1)) m.constants.push({ id: 'k.one', unit: 'ratio', value: 1 })
185
- assert.deepEqual(followOf(fill as V3Asset, 'flange', 'y'), { of: 'expression' })
185
+ /* Read as what it is since the plus is read (2026-09-25): the size less 90 -- following the size, not a share of it. */
186
+ assert.deepEqual(followOf(fill as V3Asset, 'flange', 'y'), { of: 'instance', axis: 'y', times: 1, plus: -90 })
186
187
 
187
188
  /* Before any link the shape reads the measure directly; a worked-out one there is not 「고정」 (ROBOT_ARM on :3300). */
188
189
  const direct = structuredClone(asset)
@@ -344,3 +345,81 @@ test('비튼 부품에는 모서리 맞춤을 내놓지도 보내지도 않는
344
345
  assert.deepEqual(alignOf(asset, 'blade', front, { x: 'max', y: 'mm' }, { y: 50 }, true), { y: { mm: 50 } }, '끝 맞춤은 빠지고 거리만 간다')
345
346
  assert.deepEqual(alignOf(asset, 'blade', front, { x: 'max' }, {}), { x: 'max' }, '비틀지 않으면 그대로')
346
347
  })
348
+
349
+ test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선언한다 — 그 뒤에야 부품이 도형 크기를 따를 수 있다 (사용자 09-25)', async () => {
350
+ const { instanceRangeToDeclare, instanceRangeOf, hasInstanceSize } = await import('../client/modeller/v3-links.ts')
351
+ const { compileV3Asset } = await import('@hatiolab/figure-model')
352
+ let asset = createV3Asset('Conveyor')
353
+ assert.equal(instanceRangeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
354
+ asset = addV3Box(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
355
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
356
+ assert.equal(hasInstanceSize(asset, 'x'), false, '새 도형에는 도형 크기가 없다')
357
+ const toDeclare = instanceRangeToDeclare(asset)!
358
+ assert.deepEqual(toDeclare.at, { x: 2000, y: 100, z: 600 }, '지금 부품의 크기가 선언할 크기')
359
+ asset = applyV3Authoring(asset, { kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })
360
+ assert.ok(instanceRangeOf(asset), '선언 뒤에는 놓일 수 있는 크기가 보인다')
361
+ assert.equal(instanceRangeToDeclare(asset), undefined, '이미 있으면 다시 권하지 않는다')
362
+ assert.equal(hasInstanceSize(asset, 'x'), true, '부품이 도형 크기를 따르는 선택이 열린다')
363
+ asset = applyV3Authoring(asset, { kind: 'link-dimension', part: 'frame', axis: 'x', source: { of: 'instance', axis: 'x' } } as never)
364
+ const at = (size: number) => (compileV3Asset({ ...asset, designInputs: { ...asset.designInputs, 'size.x': size } }).evaluate().geometry.find(g => g.id === 'frame')!.shape.dimensions as { width: number }).width
365
+ assert.equal(at(3000), 3000, '보드가 3000 으로 놓으면 틀도 3000')
366
+ })
367
+
368
+ test('따르는 치수의 더하기를 읽는다 — 문짝 폭 = 개구부 − 틈 (사이징, 2026-09-25)', async () => {
369
+ const { followOf, choiceOf } = await import('../client/modeller/v3-links.ts')
370
+ let asset = createV3Asset('Door')
371
+ asset = addV3Box(asset, { id: 'head', size: { x: 1100, y: 100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
372
+ asset = addV3Box(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
373
+ asset = applyV3Authoring(asset, { kind: 'link-dimension', part: 'leaf', axis: 'x', source: { of: 'part', part: 'head', axis: 'x' }, plus: -210 })
374
+ assert.deepEqual(followOf(asset, 'leaf', 'x'), { of: 'part', part: 'head', axis: 'x', plus: -210 })
375
+ assert.equal(choiceOf(followOf(asset, 'leaf', 'x')), 'part:head:x', '선택은 그대로 — 더하기는 따로')
376
+ const { compileV3Asset } = await import('@hatiolab/figure-model')
377
+ const wide = { ...asset, designInputs: { ...asset.designInputs, 'head.size.x': 1500 } }
378
+ assert.equal((compileV3Asset(wide).evaluate().geometry.find(g => g.id === 'leaf')!.shape.dimensions as { width: number }).width, 1290, '상인방 1500 이면 문짝 1290')
379
+ asset = applyV3Authoring(asset, { kind: 'declare-instance-size', range: { x: { min: 500, max: 5000 }, y: { min: 500, max: 5000 }, z: { min: 50, max: 500 } }, at: { x: 1100, y: 2200, z: 150 } })
380
+ asset = applyV3Authoring(asset, { kind: 'link-dimension', part: 'head', axis: 'x', source: { of: 'instance', axis: 'x' }, times: 0.5, plus: 20 })
381
+ assert.deepEqual(followOf(asset, 'head', 'x'), { of: 'instance', axis: 'x', times: 0.5, plus: 20 })
382
+ })
383
+
384
+ test('미끄러짐의 이동 거리가 부품 길이를 따른다 — 레일이 길어지면 브리지가 끝까지 간다 (사이징, 2026-09-25)', async () => {
385
+ const { motionJoint, motionOf } = await import('../client/modeller/v3-links.ts')
386
+ const { compileV3Asset } = await import('@hatiolab/figure-model')
387
+ let asset = createV3Asset('Gantry')
388
+ asset = addV3Box(asset, { id: 'rail', size: { x: 3000, y: 120, z: 120 }, position: { x: 0, y: 0, z: 0 } })
389
+ asset = addV3Box(asset, { id: 'bridge', size: { x: 160, y: 160, z: 1300 }, position: { x: 0, y: 0, z: 0 } })
390
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'rail', face: 'bottom', to: { plane: 'mounting-plane' } })
391
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'bridge', face: 'bottom', to: { part: 'rail', face: 'top' }, align: { x: 'min' } })
392
+ const joint = asset.joints!.find(j => j.body1.part === 'bridge')!
393
+ const form = { kind: 'slide' as const, axis: 'x' as const, id: 'x', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: 'rail:x', travelPlus: -160 }
394
+ const record = motionJoint(joint, form, 'bridge')
395
+ assert.deepEqual(record.travel, { source: { of: 'part', part: 'rail', axis: 'x' }, plus: -160 })
396
+ assert.equal(record.lowerLimit, undefined, '거리는 레일이 정한다 — mm 범위는 없다')
397
+ asset = applyV3Authoring(asset, { kind: 'set-joint', joint: record })
398
+ assert.deepEqual(motionOf(asset, 'bridge')?.travel, { part: 'rail', axis: 'x', plus: -160 }, '화면은 무엇을 따르는지 읽는다')
399
+ const end = (railLength: number) => {
400
+ const a = { ...asset, designInputs: { ...asset.designInputs, 'rail.size.x': railLength } }
401
+ const g = (id: string) => compileV3Asset(a).evaluate({ x: 1 }).geometry.find(x => x.id === id)! as unknown as { pose: { t: number[] }; shape: { dimensions: { width: number } } }
402
+ return Math.round(g('bridge').pose.t[0]! + 80 - (g('rail').pose.t[0]! + railLength / 2))
403
+ }
404
+ assert.equal(end(3000), 0, '끝까지(1) 가면 브리지 끝이 레일 끝')
405
+ assert.equal(end(4500), 0, '레일이 길어져도 끝까지')
406
+ })
407
+
408
+ test('면 위 거리를 도형 크기 비율로 — 도형이 커지면 그 자리도 비율로 옮긴다 (사이징, 2026-09-25)', async () => {
409
+ const { alignOf } = await import('../client/modeller/v3-links.ts')
410
+ const { compileV3Asset } = await import('@hatiolab/figure-model')
411
+ let asset = createV3Asset('Panel')
412
+ asset = addV3Box(asset, { id: 'panel', size: { x: 1000, y: 800, z: 40 }, position: { x: 0, y: 0, z: 0 } })
413
+ asset = addV3Box(asset, { id: 'lamp', size: { x: 60, y: 60, z: 30 }, position: { x: 0, y: 0, z: 0 } })
414
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'panel', face: 'bottom', to: { plane: 'mounting-plane' } })
415
+ const front = { part: 'panel', face: 'front' }
416
+ assert.deepEqual(alignOf(asset, 'lamp', front, { x: 'share' }, { x: 300 }), undefined, '도형 크기가 없으면 비율을 보낼 수 없다')
417
+ asset = applyV3Authoring(asset, { kind: 'declare-instance-size', range: { x: { min: 200, max: 5000 }, y: { min: 200, max: 5000 }, z: { min: 10, max: 500 } }, at: { x: 1000, y: 800, z: 40 } })
418
+ asset = applyV3Authoring(asset, { kind: 'link-dimension', part: 'panel', axis: 'x', source: { of: 'instance', axis: 'x' } })
419
+ const align = alignOf(asset, 'lamp', front, { x: 'share' }, { x: 300 })!
420
+ assert.deepEqual(align, { x: { mm: { from: { of: 'instance', axis: 'x' }, times: 0.3 } } })
421
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'lamp', face: 'back', to: front, align })
422
+ const x = (size: number) => Math.round(compileV3Asset({ ...asset, designInputs: { ...asset.designInputs, 'size.x': size } }).evaluate().geometry.find(g => g.id === 'lamp')!.pose.t[0]!)
423
+ assert.equal(x(1000), 300)
424
+ assert.equal(x(2000), 600, '도형이 두 배면 거리도 두 배')
425
+ })
@@ -26,3 +26,24 @@ test('위쪽 끝도, 부품 이름이 없어도', () => {
26
26
  test('다른 거절은 건드리지 않는다', () => {
27
27
  assert.equal(sizeRangeRefusalOf(Object.assign(new Error('x'), { code: 'EDIT_TARGET' }), say), null)
28
28
  })
29
+
30
+ test('범위가 받을 수 없는 연결은 같은 연결을 범위를 좁혀 다시 권한다 — 다시 재기로는 풀리지 않는다 (사이징, 2026-09-25)', async () => {
31
+ const { narrowingRetryOf } = await import('../client/modeller/v3-refusals.ts')
32
+ const { addV3Box, applyV3Authoring, createV3Asset } = await import('@hatiolab/figure-model')
33
+ let asset = addV3Box(createV3Asset('Panel'), { id: 'panel', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 0, z: 0 } })
34
+ asset = applyV3Authoring(asset, { kind: 'declare-instance-size', range: { x: { min: 1, max: 4000 }, y: { min: 1, max: 4000 }, z: { min: 1, max: 4000 } }, at: { x: 400, y: 400, z: 400 } })
35
+ const link = { kind: 'link-dimension' as const, part: 'panel', axis: 'x' as const, source: { of: 'instance' as const, axis: 'x' as const }, plus: -50 }
36
+ let error: unknown
37
+ try {
38
+ applyV3Authoring(asset, link)
39
+ } catch (e) {
40
+ error = e
41
+ }
42
+ const say = (key: string, p: Record<string, string>) => `${key} ${JSON.stringify(p)}`
43
+ const offer = narrowingRetryOf(link, error, say)!
44
+ assert.ok(offer, '권한다')
45
+ assert.match(offer.label, /figure.button.narrow-range-from .*"axis":"X"/)
46
+ assert.deepEqual(offer.retry, { ...link, narrowRange: true })
47
+ assert.doesNotThrow(() => applyV3Authoring(asset, offer.retry as never), '권한 것은 받아들여진다')
48
+ assert.equal(narrowingRetryOf({ kind: 'attach' }, error, say), null, '연결이 아닌 명령에는 권하지 않는다')
49
+ })
@@ -708,5 +708,17 @@
708
708
  "figure.label.repeat-fills": "as many as fill the length",
709
709
  "figure.button.make-repeat": "make a row",
710
710
  "figure.button.remove-repeat": "remove the row",
711
- "figure.label.twist": "twist (°)"
711
+ "figure.label.twist": "twist (°)",
712
+ "figure.text.instance-size-undeclared": "the figure has no size, so a board cannot resize it.",
713
+ "figure.button.declare-instance-size": "declare the figure size",
714
+ "figure.text.candidate-cannot-open": "this candidate is a V2 figure and the modeller opens V3 figures only, so it cannot be opened for review yet.",
715
+ "figure.label.follows-plus": "plus (mm)",
716
+ "figure.label.motion-travel": "travel",
717
+ "figure.label.motion-travel-mm": "its own range (mm)",
718
+ "figure.label.motion-travel-is": "travel: {part} · {axis} + {plus} mm",
719
+ "figure.label.align-share": "offset from centre, as a share of the figure size",
720
+ "figure.text.link-needs-smaller-range": "with this link {part} is gone when the figure's {axis} is under {mm} mm, and the declared sizes go below that.",
721
+ "figure.button.narrow-range-from": "narrow {axis} to {mm} mm and up, and link",
722
+ "figure.text.link-needs-larger-range": "with this link the figure cannot be drawn when its {axis} is over {mm} mm, and the declared sizes go above that.",
723
+ "figure.button.narrow-range-to": "narrow {axis} to {mm} mm and under, and link"
712
724
  }
@@ -708,5 +708,17 @@
708
708
  "figure.label.repeat-fills": "長さを満たすだけ",
709
709
  "figure.button.make-repeat": "繰り返しを作る",
710
710
  "figure.button.remove-repeat": "繰り返しをなくす",
711
- "figure.label.twist": "ひねり (°)"
711
+ "figure.label.twist": "ひねり (°)",
712
+ "figure.text.instance-size-undeclared": "図形サイズがないため、ボードでサイズを変えられません。",
713
+ "figure.button.declare-instance-size": "図形サイズを宣言",
714
+ "figure.text.candidate-cannot-open": "この候補は V2 図形で、モデラーは V3 図形しか開けないため、まだレビュー用に開けません。",
715
+ "figure.label.follows-plus": "加算 (mm)",
716
+ "figure.label.motion-travel": "移動距離",
717
+ "figure.label.motion-travel-mm": "自分の範囲 (mm)",
718
+ "figure.label.motion-travel-is": "移動距離: {part} · {axis} + {plus} mm",
719
+ "figure.label.align-share": "中央からの距離 — 図形サイズの比率で",
720
+ "figure.text.link-needs-smaller-range": "この連結で図形の {axis} が {mm} mm 未満だと {part} がなくなります。宣言された置けるサイズはそれより小さくまで開いています。",
721
+ "figure.button.narrow-range-from": "{axis} を {mm} mm からに絞って連結",
722
+ "figure.text.link-needs-larger-range": "この連結で図形の {axis} が {mm} mm を超えると描けません。宣言された置けるサイズはそれより大きくまで開いています。",
723
+ "figure.button.narrow-range-to": "{axis} を {mm} mm までに絞って連結"
712
724
  }
@@ -708,5 +708,17 @@
708
708
  "figure.label.repeat-fills": "길이를 채우는 만큼",
709
709
  "figure.button.make-repeat": "되풀이 만들기",
710
710
  "figure.button.remove-repeat": "되풀이 없애기",
711
- "figure.label.twist": "비틀기 (°)"
711
+ "figure.label.twist": "비틀기 (°)",
712
+ "figure.text.instance-size-undeclared": "도형 크기가 없어 보드에서 크기를 바꿀 수 없습니다.",
713
+ "figure.button.declare-instance-size": "도형 크기 선언",
714
+ "figure.text.candidate-cannot-open": "이 후보는 V2 도형이고 모델러는 V3 도형만 열 수 있어, 아직 검토하도록 열 수 없습니다.",
715
+ "figure.label.follows-plus": "더하기 (mm)",
716
+ "figure.label.motion-travel": "이동 거리",
717
+ "figure.label.motion-travel-mm": "제 범위 (mm)",
718
+ "figure.label.motion-travel-is": "이동 거리: {part} · {axis} + {plus} mm",
719
+ "figure.label.align-share": "가운데서 거리 — 도형 크기 비율로",
720
+ "figure.text.link-needs-smaller-range": "이 연결로 {part} 부품은 도형 {axis} 가 {mm} mm 보다 작으면 사라집니다. 지금 선언된 놓일 수 있는 크기는 그보다 작은 데까지 열려 있습니다.",
721
+ "figure.button.narrow-range-from": "{axis} 를 {mm} mm 부터로 좁히고 연결",
722
+ "figure.text.link-needs-larger-range": "이 연결로 도형 {axis} 가 {mm} mm 보다 크면 그릴 수 없습니다. 지금 선언된 놓일 수 있는 크기는 그보다 큰 데까지 열려 있습니다.",
723
+ "figure.button.narrow-range-to": "{axis} 를 {mm} mm 까지로 좁히고 연결"
712
724
  }
@@ -708,5 +708,17 @@
708
708
  "figure.label.repeat-fills": "sebanyak yang memenuhi panjang",
709
709
  "figure.button.make-repeat": "buat baris",
710
710
  "figure.button.remove-repeat": "buang baris",
711
- "figure.label.twist": "pusingan (°)"
711
+ "figure.label.twist": "pusingan (°)",
712
+ "figure.text.instance-size-undeclared": "rajah tiada saiz, jadi papan tidak boleh mengubah saiznya.",
713
+ "figure.button.declare-instance-size": "isytihar saiz rajah",
714
+ "figure.text.candidate-cannot-open": "calon ini ialah rajah V2 dan pemodel hanya membuka rajah V3, jadi ia belum boleh dibuka untuk semakan.",
715
+ "figure.label.follows-plus": "tambah (mm)",
716
+ "figure.label.motion-travel": "jarak gerak",
717
+ "figure.label.motion-travel-mm": "julat sendiri (mm)",
718
+ "figure.label.motion-travel-is": "jarak gerak: {part} · {axis} + {plus} mm",
719
+ "figure.label.align-share": "jarak dari tengah — nisbah saiz rajah",
720
+ "figure.text.link-needs-smaller-range": "dengan pautan ini {part} hilang apabila {axis} rajah di bawah {mm} mm, dan saiz yang diisytihar pergi lebih rendah.",
721
+ "figure.button.narrow-range-from": "sempitkan {axis} dari {mm} mm dan pautkan",
722
+ "figure.text.link-needs-larger-range": "dengan pautan ini rajah tidak boleh dilukis apabila {axis} melebihi {mm} mm, dan saiz yang diisytihar pergi lebih tinggi.",
723
+ "figure.button.narrow-range-to": "sempitkan {axis} hingga {mm} mm dan pautkan"
712
724
  }
@@ -708,5 +708,17 @@
708
708
  "figure.label.repeat-fills": "填满长度的数量",
709
709
  "figure.button.make-repeat": "生成重复",
710
710
  "figure.button.remove-repeat": "取消重复",
711
- "figure.label.twist": "扭转 (°)"
711
+ "figure.label.twist": "扭转 (°)",
712
+ "figure.text.instance-size-undeclared": "图形没有尺寸,看板上无法调整大小。",
713
+ "figure.button.declare-instance-size": "声明图形尺寸",
714
+ "figure.text.candidate-cannot-open": "此候选为 V2 图形,而建模器只能打开 V3 图形,因此暂时无法打开以供审阅。",
715
+ "figure.label.follows-plus": "加 (mm)",
716
+ "figure.label.motion-travel": "行程",
717
+ "figure.label.motion-travel-mm": "自身范围 (mm)",
718
+ "figure.label.motion-travel-is": "行程: {part} · {axis} + {plus} mm",
719
+ "figure.label.align-share": "距中心 — 按图形尺寸比例",
720
+ "figure.text.link-needs-smaller-range": "此连接下,图形 {axis} 小于 {mm} mm 时 {part} 会消失,而声明的可放置尺寸低于此值。",
721
+ "figure.button.narrow-range-from": "将 {axis} 收窄为 {mm} mm 起并连接",
722
+ "figure.text.link-needs-larger-range": "此连接下,图形 {axis} 大于 {mm} mm 时无法绘制,而声明的可放置尺寸高于此值。",
723
+ "figure.button.narrow-range-to": "将 {axis} 收窄为 {mm} mm 以内并连接"
712
724
  }
@@ -1,23 +0,0 @@
1
- /** Explicit AI entry survives lazy creation of the app-level dock. */
2
- let pending: { figureId?: string; source?: unknown } | undefined
3
- export function requestFigureAI(target: { figureId?: string; source?: unknown }) {
4
- pending = structuredClone(target)
5
- window.dispatchEvent(new CustomEvent('figure-ai-target'))
6
- }
7
- export function takeFigureAITarget() {
8
- const target = pending
9
- pending = undefined
10
- return target
11
- }
12
-
13
- let pendingStage: { figureId?: string; source?: any; proposal?: any } | undefined
14
- export function stageFigureProposal(data: { figureId?: string; source?: any; proposal?: any }) {
15
- pendingStage = structuredClone(data)
16
- window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }))
17
- }
18
-
19
- export function consumePendingFigureProposal() {
20
- const target = pendingStage
21
- pendingStage = undefined
22
- return target
23
- }
@@ -1,18 +0,0 @@
1
- export declare function requestFigureAI(target: {
2
- figureId?: string;
3
- source?: unknown;
4
- }): void;
5
- export declare function takeFigureAITarget(): {
6
- figureId?: string;
7
- source?: unknown;
8
- } | undefined;
9
- export declare function stageFigureProposal(data: {
10
- figureId?: string;
11
- source?: any;
12
- proposal?: any;
13
- }): void;
14
- export declare function consumePendingFigureProposal(): {
15
- figureId?: string;
16
- source?: any;
17
- proposal?: any;
18
- } | undefined;
@@ -1,22 +0,0 @@
1
- /** Explicit AI entry survives lazy creation of the app-level dock. */
2
- let pending;
3
- export function requestFigureAI(target) {
4
- pending = structuredClone(target);
5
- window.dispatchEvent(new CustomEvent('figure-ai-target'));
6
- }
7
- export function takeFigureAITarget() {
8
- const target = pending;
9
- pending = undefined;
10
- return target;
11
- }
12
- let pendingStage;
13
- export function stageFigureProposal(data) {
14
- pendingStage = structuredClone(data);
15
- window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }));
16
- }
17
- export function consumePendingFigureProposal() {
18
- const target = pendingStage;
19
- pendingStage = undefined;
20
- return target;
21
- }
22
- //# sourceMappingURL=figure-ai-target.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC;AAED,IAAI,YAA6E,CAAA;AACjF,MAAM,UAAU,mBAAmB,CAAC,IAAyD;IAC3F,YAAY,GAAG,eAAe,CAAC,IAAI,CAAC,CAAA;IACpC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,iBAAiB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;AAC5E,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,MAAM,MAAM,GAAG,YAAY,CAAA;IAC3B,YAAY,GAAG,SAAS,CAAA;IACxB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n\nlet pendingStage: { figureId?: string; source?: any; proposal?: any } | undefined\nexport function stageFigureProposal(data: { figureId?: string; source?: any; proposal?: any }) {\n pendingStage = structuredClone(data)\n window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }))\n}\n\nexport function consumePendingFigureProposal() {\n const target = pendingStage\n pendingStage = undefined\n return target\n}\n"]}
@@ -1,25 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import { test } from 'node:test'
3
- import { requestFigureAI, takeFigureAITarget } from '../client/modeller/figure-ai-target.ts'
4
-
5
- test('AI 진입은 늦게 생성되는 Dock에도 복제된 원본을 한 번 전달한다', () => {
6
- const oldWindow = globalThis.window
7
- globalThis.window = new EventTarget() as any
8
- try {
9
- const source = { type: 'TEST', parts: [{ name: 'cube', size: { y: 100 } }] }
10
- requestFigureAI({ figureId: 'a', source })
11
- source.parts[0].size.y = 200
12
- assert.equal((takeFigureAITarget()?.source as any).parts[0].size.y, 100)
13
- assert.equal(takeFigureAITarget(), undefined)
14
- } finally { globalThis.window = oldWindow }
15
- })
16
-
17
- test('연속된 명시적 진입에서는 최신 대상만 지연 Dock에 전달한다', () => {
18
- const oldWindow = globalThis.window
19
- globalThis.window = new EventTarget() as any
20
- try {
21
- requestFigureAI({ figureId: 'a' })
22
- requestFigureAI({ figureId: 'b' })
23
- assert.equal(takeFigureAITarget()?.figureId, 'b')
24
- } finally { globalThis.window = oldWindow }
25
- })