@things-factory/figure-ui 10.1.269 → 10.1.271

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 (69) hide show
  1. package/client/graphql/index.ts +2 -0
  2. package/client/modeller/data-interface.ts +14 -0
  3. package/client/modeller/editor-styles.ts +11 -0
  4. package/client/modeller/editor.ts +39 -11
  5. package/client/modeller/fasten-form.ts +6 -1
  6. package/client/modeller/fasten-geometry.ts +11 -1
  7. package/client/modeller/figure-fasten-drawing.ts +2 -3
  8. package/client/modeller/figure-preview.ts +9 -1
  9. package/client/modeller/palette.ts +27 -0
  10. package/client/modeller/product-session.ts +5 -1
  11. package/client/types.ts +2 -0
  12. package/dist-client/graphql/index.js +2 -0
  13. package/dist-client/graphql/index.js.map +1 -1
  14. package/dist-client/modeller/clips.d.ts +2 -2
  15. package/dist-client/modeller/data-interface.d.ts +5 -0
  16. package/dist-client/modeller/data-interface.js +15 -0
  17. package/dist-client/modeller/data-interface.js.map +1 -1
  18. package/dist-client/modeller/editor-styles.js +11 -0
  19. package/dist-client/modeller/editor-styles.js.map +1 -1
  20. package/dist-client/modeller/editor.d.ts +6 -0
  21. package/dist-client/modeller/editor.js +35 -11
  22. package/dist-client/modeller/editor.js.map +1 -1
  23. package/dist-client/modeller/fasten-form.js +6 -2
  24. package/dist-client/modeller/fasten-form.js.map +1 -1
  25. package/dist-client/modeller/fasten-geometry.d.ts +6 -0
  26. package/dist-client/modeller/fasten-geometry.js +10 -1
  27. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  28. package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
  29. package/dist-client/modeller/figure-cavity-drawing.d.ts +1 -1
  30. package/dist-client/modeller/figure-clips.d.ts +1 -1
  31. package/dist-client/modeller/figure-controls.d.ts +1 -1
  32. package/dist-client/modeller/figure-data-panel.d.ts +1 -1
  33. package/dist-client/modeller/figure-face-mapping.d.ts +1 -1
  34. package/dist-client/modeller/figure-fasten-dialog.d.ts +1 -1
  35. package/dist-client/modeller/figure-fasten-drawing.d.ts +1 -1
  36. package/dist-client/modeller/figure-fasten-drawing.js +2 -3
  37. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  38. package/dist-client/modeller/figure-links.d.ts +1 -1
  39. package/dist-client/modeller/figure-material-panel.d.ts +1 -1
  40. package/dist-client/modeller/figure-pipe-panel.d.ts +1 -1
  41. package/dist-client/modeller/figure-preview.js +9 -1
  42. package/dist-client/modeller/figure-preview.js.map +1 -1
  43. package/dist-client/modeller/figure-section-editor.d.ts +1 -1
  44. package/dist-client/modeller/figure-section-panel.d.ts +1 -1
  45. package/dist-client/modeller/figure-size-editor.d.ts +1 -1
  46. package/dist-client/modeller/figure-slot-layout-panel.d.ts +1 -1
  47. package/dist-client/modeller/group-menu.d.ts +1 -1
  48. package/dist-client/modeller/help.d.ts +1 -1
  49. package/dist-client/modeller/palette.d.ts +14 -0
  50. package/dist-client/modeller/palette.js +23 -0
  51. package/dist-client/modeller/palette.js.map +1 -1
  52. package/dist-client/modeller/product-session.js +6 -2
  53. package/dist-client/modeller/product-session.js.map +1 -1
  54. package/dist-client/route.d.ts +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/dist-client/types.d.ts +2 -0
  57. package/dist-client/types.js.map +1 -1
  58. package/package.json +5 -5
  59. package/test/data-motion.test.ts +8 -0
  60. package/test/fasten-form.test.ts +13 -0
  61. package/test/fasten-geometry.test.ts +25 -1
  62. package/test/first-part-on-plane.test.ts +52 -0
  63. package/test/save-landed.test.ts +20 -1
  64. package/translations/en.json +1 -0
  65. package/translations/es.json +1 -0
  66. package/translations/ja.json +1 -0
  67. package/translations/ko.json +1 -0
  68. package/translations/ms.json +1 -0
  69. package/translations/zh.json +1 -0
@@ -125,6 +125,8 @@ export interface FigureGateFinding {
125
125
  /** 갈라진 부품 이름들, 또는 정본 안의 경로. */
126
126
  at?: string;
127
127
  blocking: boolean;
128
+ /** 발행 계약이 깨짐 — 새 피규어 타입으로 저장하면 발행되고, 놓인 피규어는 옛 정의를 유지한다. */
129
+ asNewType?: boolean;
128
130
  }
129
131
  /** 발행해도 되나. `blocked` 면 서버가 거절한다. */
130
132
  export interface FigureInspection {
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"AAAA;;GAEG","sourcesContent":["/**\n * 화면이 다루는 모양. 피규어 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.\n */\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n /** Namespace of the full name {namespace}/{type} (ADR-0114). */\n namespace?: string | null\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** 피규어 자산 JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n /** Retired: still released and served, no longer offered for new use. */\n deprecated?: boolean\n /** The type to use instead of a retired figure, in the same domain. */\n supersededBy?: string | null\n /** Public: other domains read its releases and draw it by full name (LV figure-server design G2). */\n public?: boolean\n /** The domain it belongs to; carried by another domain's public figure, so a screen says whose it is. */\n domain?: { id: string; name?: string; subdomain?: string } | null\n}\n\n/**\n * 발행된 버전 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 버전은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"AAAA;;GAEG","sourcesContent":["/**\n * 화면이 다루는 모양. 피규어 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.\n */\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n /** Namespace of the full name {namespace}/{type} (ADR-0114). */\n namespace?: string | null\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** 피규어 자산 JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n /** Retired: still released and served, no longer offered for new use. */\n deprecated?: boolean\n /** The type to use instead of a retired figure, in the same domain. */\n supersededBy?: string | null\n /** Public: other domains read its releases and draw it by full name (LV figure-server design G2). */\n public?: boolean\n /** The domain it belongs to; carried by another domain's public figure, so a screen says whose it is. */\n domain?: { id: string; name?: string; subdomain?: string } | null\n}\n\n/**\n * 발행된 버전 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 버전은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n blocking: boolean\n /** 발행 계약이 깨짐 — 새 피규어 타입으로 저장하면 발행되고, 놓인 피규어는 옛 정의를 유지한다. */\n asNewType?: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.269",
3
+ "version": "10.1.271",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.58",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.269",
46
- "@things-factory/figure-service": "^10.1.269",
45
+ "@things-factory/ai-assistant": "^10.1.271",
46
+ "@things-factory/figure-service": "^10.1.271",
47
47
  "@things-factory/figure-viewer": "^10.1.266",
48
- "@things-factory/setting-base": "^10.1.269",
48
+ "@things-factory/setting-base": "^10.1.271",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "d4fbddbb97458c60e7c32a2b3c8e4d04bab95464"
51
+ "gitHead": "843bc2608d8c89348ced871777535966758927db"
52
52
  }
@@ -33,6 +33,7 @@ import {
33
33
  tableWithLook,
34
34
  tableWithTargets,
35
35
  sampleOf,
36
+ sampledStatesOf,
36
37
  schemaWithField,
37
38
  schemaWithoutField,
38
39
  typedValueOf,
@@ -363,3 +364,10 @@ test('the value data gives a row is read down its accessor; left out or null, th
363
364
  assert.equal(dataValueAt(data, 'missing.deep'), undefined)
364
365
  assert.equal(dataValueAt(null, 'status'), undefined)
365
366
  })
367
+
368
+ test('the preview says the state inputs a sample sets, not a slider at their default (tutorial 2026-11-11)', () => {
369
+ /* The dog's head follows headAngle: a sample giving it sets the head; none, or one the figure refuses, sets nothing. */
370
+ assert.deepEqual(sampledStatesOf(DOG, { headAngle: 10 }), { head: 10 })
371
+ assert.deepEqual(sampledStatesOf(DOG, null), {})
372
+ assert.deepEqual(sampledStatesOf(DOG, { headAngle: 999 }), {}, 'refused whole, as a board refuses it')
373
+ })
@@ -203,3 +203,16 @@ test("pressed on the canvas: the face or the point pressed, read in that part's
203
203
  const corner = aimFastenAt(r, 'tag', freshFasten(), { id: 'soft', point: [145, 100, 1645], normal: [Math.SQRT1_2, 0, Math.SQRT1_2] })
204
204
  assert.deepEqual([corner.note, corner.form?.target], ['aim-not-here', 'soft'], 'a rounded corner: aimed at the part, said')
205
205
  })
206
+
207
+ test('a part still where the palette put it is fastened on the face; one the author moved keeps its place (tutorial 2026-11-11)', () => {
208
+ /* A body on the floor; a mast added from the palette stands inside it, at the palette's place (0, 50, 0). */
209
+ let a = fasten(box(createFigureAsset('Fork AGV'), 'body', [1000, 300, 700], [0, 150, 0]), 'body', 'plane')
210
+ a = box(a, 'mast', [80, 1500, 700], [0, 50, 0])
211
+ const r = revisionOf(a)
212
+ const aimed = keptGapOf(r, 'mast', aimFasten(r, 'mast', fastenFormOf(r, 'mast'), { part: 'body' }))
213
+ assert.equal(aimed.gap, 0, 'on the face, not 550 mm inside the body')
214
+ /* Moved by the author, it is fastened where it stands (user 2026-10-20 「현재 상태로 붙이기」). */
215
+ const moved = revisionOf(box(fasten(box(createFigureAsset('Fork AGV'), 'body', [1000, 300, 700], [0, 150, 0]), 'body', 'plane'), 'mast', [80, 1500, 700], [0, 1100, 0]))
216
+ const kept = keptGapOf(moved, 'mast', aimFasten(moved, 'mast', fastenFormOf(moved, 'mast'), { part: 'body' }))
217
+ assert.equal(kept.gap, 50, 'the 50 mm its bottom stands above the body')
218
+ })
@@ -11,7 +11,7 @@ import assert from 'node:assert/strict'
11
11
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
12
12
  import type { FigureAsset, FigureAuthoringAction } from '@hatiolab/figure-model'
13
13
 
14
- import { alignmentAt, faceViewOf, facesAsDrawnOf, offsetOf, overhangOf, screenOf } from '../client/modeller/fasten-geometry.ts'
14
+ import { alignmentAt, drawingSpanOf, faceViewOf, facesAsDrawnOf, offsetOf, overhangOf, screenOf } from '../client/modeller/fasten-geometry.ts'
15
15
  import { revisionOf } from './revision-of.ts'
16
16
 
17
17
  const scene = (lidRotation?: { x: number; y: number; z: number }): FigureAsset => {
@@ -97,3 +97,27 @@ test('붙일 면 그림은 그려진 자세대로 — 눕힌 원기둥의 위·
97
97
  assert.ok(!['top', 'bottom'].includes(drawn.top) && !['top', 'bottom'].includes(drawn.bottom), `화면의 위·아래는 원기둥의 면이 아니다: ${drawn.top}, ${drawn.bottom}`)
98
98
  assert.deepEqual([drawn.front, drawn.back], ['front', 'back'])
99
99
  })
100
+
101
+ test('a part bigger than the face is caught level with its edges too -- a mast flush with the bottom of a low body (tutorial 2026-11-11)', () => {
102
+ /* A 1500 mm mast on the 300 mm high side of a body: its bottom level with the body's is its centre 600 mm up. */
103
+ assert.deepEqual(alignmentAt(595, 300, 1500, true, 10), { how: 'min', caught: 'inside' })
104
+ assert.deepEqual(alignmentAt(-603, 300, 1500, true, 10), { how: 'max', caught: 'inside' })
105
+ assert.equal(offsetOf('min', undefined, 300, 1500), 600)
106
+ /* What the drawing names, the kernel does: the mast's bottom on the body's bottom, the floor. */
107
+ let a = addFigureBox(createFigureAsset('Fork AGV'), { id: 'body', size: { x: 1000, y: 300, z: 700 }, position: { x: 0, y: 150, z: 0 } })
108
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
109
+ a = addFigureBox(a, { id: 'mast', size: { x: 80, y: 1500, z: 700 }, position: { x: 0, y: 750, z: 0 } })
110
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'mast', to: { part: 'body', face: 'left' }, facing: 'meet', align: { y: 'min', z: 'centre' } } as FigureAuthoringAction)
111
+ const t = at(a, 'mast')
112
+ assert.ok(Math.abs(t[1]! - 750) < 1e-6, `mast centre ${t[1]} mm high`)
113
+ assert.ok(Math.abs(t[0]! - -540) < 1e-6, `mast against the left face, ${t[0]}`)
114
+ })
115
+
116
+ test('the face drawing shows a part bigger than the face whole when lined up with an edge', () => {
117
+ /* The mast's top at its centre 600 mm up, half of 1500 above that: 1350 mm from the drawing's middle, inside half its span. */
118
+ const span = drawingSpanOf(300, 1500)
119
+ assert.ok(span / 2 >= Math.abs(offsetOf('min', undefined, 300, 1500)) + 1500 / 2, `span ${span}`)
120
+ /* A part smaller than the face keeps the room it had: the face and half the part past each edge. */
121
+ assert.equal(drawingSpanOf(600, 300), 900)
122
+ assert.equal(drawingSpanOf(undefined, 200), 600)
123
+ })
@@ -0,0 +1,52 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { compileFigureAsset, parseFigureAsset } from '@hatiolab/figure-model'
4
+ import { createFigureProductSession } from '../client/modeller/product-session.js'
5
+ import { firstPartFastensToPlane, paletteRecipe } from '../client/modeller/palette.js'
6
+
7
+ /*
8
+ * A figure that stands on the floor starts with its base: the first box the author adds and then sizes stays on the floor
9
+ * (tutorial 2026-11-11: a 1000 x 300 x 700 box added from the start dialog sank 100 mm through the floor).
10
+ */
11
+ const session = () => createFigureProductSession({ load: async () => ({ id: 'x', source: '' }), save: async q => ({ id: 'x', source: q.source }) })
12
+
13
+ /** The bottom of the part, in mm above the mounting plane, once its height is `height` */
14
+ function bottomAt(source: string, part: string, height: number) {
15
+ const asset = parseFigureAsset(source)
16
+ const key = Object.keys(asset.designInputs).find(k => k.startsWith(part) && /size\.y$|dimension\.y$|height$/.test(k))
17
+ assert.ok(key, `the part's height input among ${Object.keys(asset.designInputs).join(', ')}`)
18
+ const g = compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, [key]: height } })
19
+ .evaluate()
20
+ .geometry.find(g => g.id === part)!
21
+ return g.pose.t[1] - height / 2
22
+ }
23
+
24
+ test('only the first part of a figure on a plane is fastened to it', () => {
25
+ assert.equal(firstPartFastensToPlane([], undefined), true)
26
+ assert.equal(firstPartFastensToPlane([], 'floor'), true)
27
+ assert.equal(firstPartFastensToPlane([], 'ceiling'), true)
28
+ assert.equal(firstPartFastensToPlane([], 'wall'), true)
29
+ assert.equal(firstPartFastensToPlane([], 'center'), false)
30
+ assert.equal(firstPartFastensToPlane([{ id: 'body' }], 'floor'), false)
31
+ })
32
+
33
+ test('the first box, added fastened to the floor, stays on it when its height changes; one undo takes it away', () => {
34
+ const s = session()
35
+ s.startNew({ name: 'fork AGV' })
36
+ const empty = s.source()
37
+ const part = paletteRecipe('box', 'body')
38
+ s.applyRecipe([
39
+ { kind: 'add-part', part },
40
+ { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } }
41
+ ])
42
+ for (const height of [100, 300, 1200]) assert.ok(Math.abs(bottomAt(s.source(), 'body', height)) < 1e-6, `bottom on the floor at ${height} mm`)
43
+ s.undo()
44
+ assert.equal(s.source(), empty)
45
+ })
46
+
47
+ test('a box added without fastening sinks as it grows -- what the first part no longer does', () => {
48
+ const s = session()
49
+ s.startNew({ name: 'fork AGV' })
50
+ s.add(paletteRecipe('box', 'body'))
51
+ assert.ok(bottomAt(s.source(), 'body', 300) < -50)
52
+ })
@@ -258,7 +258,6 @@ test('a conflict kept as a copy: a new figure under a new creation id, the edits
258
258
  const session = createFigureProductSession(store)
259
259
  await session.open('a')
260
260
  const opened = session.read().creationId
261
- assert.throws(() => session.detachAsCopy(), { code: 'SESSION_NO_CONFLICT' }, 'only after a conflict')
262
261
  session.command({ kind: 'rename', name: 'Mine' } as never)
263
262
  session.editCatalog({ category: 'equipment', tags: ['new-tag'], type: 'gate-2' }, { type: 'gate' })
264
263
  const theirs = serializeFigureAsset(createFigureAsset('Theirs'))
@@ -289,3 +288,23 @@ test('the latest not readable: the conflict stands, and it cannot be written ove
289
288
  assert.deepEqual(session.read().conflict, { latest: undefined })
290
289
  assert.throws(() => session.overwriteConflict(), { code: 'SESSION_NO_CONFLICT' })
291
290
  })
291
+
292
+ test('edits a released figure cannot take kept as a new figure type: no conflict needed, the released one untouched', async () => {
293
+ const s = server()
294
+ const saved: { id?: string; creationId?: string; type?: string }[] = []
295
+ const store = {
296
+ ...s.store,
297
+ save: async (q: { id?: string; source: string; creationId?: string; type?: string }) => (saved.push(q), q.id ? s.store.save(q) : { id: q.creationId!, source: q.source })
298
+ }
299
+ const session = createFigureProductSession(store)
300
+ assert.throws(() => session.detachAsCopy(), { code: 'SESSION_NOT_SAVED' }, 'nothing opened, nothing to keep apart')
301
+ await session.open('a')
302
+ const before = s.row()
303
+ session.command({ kind: 'rename', name: 'Mine' } as never)
304
+ const copy = session.detachAsCopy()
305
+ assert.equal(copy.id, undefined)
306
+ assert.equal(copy.name, 'Mine')
307
+ await session.save({ type: 'gate-v2' })
308
+ assert.deepEqual(saved.at(-1), { ...saved.at(-1), id: undefined, creationId: copy.creationId, type: 'gate-v2' })
309
+ assert.equal(s.row(), before, 'the figure it came from is as the server had it')
310
+ })
@@ -1281,6 +1281,7 @@
1281
1281
  "figure.text.conflict-other-changed": "Settings outside the parts (name, inputs, capabilities...) differ too.",
1282
1282
  "figure.text.conflict-latest-unread": "The latest could not be read, so it was not compared. It cannot be overwritten.",
1283
1283
  "figure.button.save-as-copy": "save as a copy",
1284
+ "figure.button.save-as-new-type": "save as a new figure type",
1284
1285
  "figure.button.open-latest": "open the latest in a new tab",
1285
1286
  "figure.button.overwrite-latest": "overwrite with my edits",
1286
1287
  "figure.text.overwrite-latest-warning": "What changed in the latest is replaced by your edits and not kept. Overwrite?",
@@ -1281,6 +1281,7 @@
1281
1281
  "figure.text.conflict-other-changed": "También difieren ajustes fuera de las partes (nombre, entradas, capacidades...).",
1282
1282
  "figure.text.conflict-latest-unread": "No se pudo leer la última versión, así que no se comparó. No se puede sobrescribir.",
1283
1283
  "figure.button.save-as-copy": "guardar como copia",
1284
+ "figure.button.save-as-new-type": "guardar como un nuevo tipo de figura",
1284
1285
  "figure.button.open-latest": "abrir la última versión en una pestaña nueva",
1285
1286
  "figure.button.overwrite-latest": "sobrescribir con mis ediciones",
1286
1287
  "figure.text.overwrite-latest-warning": "Lo que cambió en la última versión se reemplaza por tus ediciones y no se conserva. ¿Sobrescribir?",
@@ -1281,6 +1281,7 @@
1281
1281
  "figure.text.conflict-other-changed": "部品以外の設定(名前・入力・能力など)も異なります。",
1282
1282
  "figure.text.conflict-latest-unread": "最新版を読み込めなかったため比較できませんでした。上書きはできません。",
1283
1283
  "figure.button.save-as-copy": "コピーとして保存",
1284
+ "figure.button.save-as-new-type": "新しいフィギュアタイプとして保存",
1284
1285
  "figure.button.open-latest": "最新版を新しいタブで開く",
1285
1286
  "figure.button.overwrite-latest": "自分の編集で上書き",
1286
1287
  "figure.text.overwrite-latest-warning": "最新版で変更された内容は自分の編集に置き換えられ、残りません。上書きしますか?",
@@ -1281,6 +1281,7 @@
1281
1281
  "figure.text.conflict-other-changed": "부품 밖의 설정(이름·입력·능력 등)도 다릅니다.",
1282
1282
  "figure.text.conflict-latest-unread": "최신본을 읽지 못해 비교하지 못했습니다. 덮어쓰기는 할 수 없습니다.",
1283
1283
  "figure.button.save-as-copy": "사본으로 저장",
1284
+ "figure.button.save-as-new-type": "새 피규어 타입으로 저장",
1284
1285
  "figure.button.open-latest": "최신본을 새 탭에서 열기",
1285
1286
  "figure.button.overwrite-latest": "내 편집으로 덮어쓰기",
1286
1287
  "figure.text.overwrite-latest-warning": "최신본에서 바뀐 내용은 내 편집으로 바뀌고 남지 않습니다. 덮어쓸까요?",
@@ -1281,6 +1281,7 @@
1281
1281
  "figure.text.conflict-other-changed": "Tetapan di luar bahagian (nama, input, keupayaan...) juga berbeza.",
1282
1282
  "figure.text.conflict-latest-unread": "Versi terkini tidak dapat dibaca, jadi tidak dibandingkan. Ia tidak boleh ditulis ganti.",
1283
1283
  "figure.button.save-as-copy": "simpan sebagai salinan",
1284
+ "figure.button.save-as-new-type": "simpan sebagai jenis figure baharu",
1284
1285
  "figure.button.open-latest": "buka versi terkini dalam tab baharu",
1285
1286
  "figure.button.overwrite-latest": "tulis ganti dengan suntingan saya",
1286
1287
  "figure.text.overwrite-latest-warning": "Perubahan dalam versi terkini akan diganti oleh suntingan anda dan tidak disimpan. Tulis ganti?",
@@ -1281,6 +1281,7 @@
1281
1281
  "figure.text.conflict-other-changed": "部件以外的设置(名称、输入、能力等)也不同。",
1282
1282
  "figure.text.conflict-latest-unread": "无法读取最新版本,因此未能比较。无法覆盖。",
1283
1283
  "figure.button.save-as-copy": "另存为副本",
1284
+ "figure.button.save-as-new-type": "另存为新的 figure 类型",
1284
1285
  "figure.button.open-latest": "在新标签页中打开最新版本",
1285
1286
  "figure.button.overwrite-latest": "用我的编辑覆盖",
1286
1287
  "figure.text.overwrite-latest-warning": "最新版本中的更改将被你的编辑替换且不会保留。要覆盖吗?",