@things-factory/figure-ui 10.1.68 → 10.1.69

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 (60) hide show
  1. package/client/graphql/index.ts +16 -0
  2. package/client/modeller/figure-v3-links.ts +73 -7
  3. package/client/modeller/figure-v3-parts.ts +153 -2
  4. package/client/modeller/v3-editor.ts +133 -5
  5. package/client/modeller/v3-gizmo.ts +18 -2
  6. package/client/modeller/v3-links.ts +71 -2
  7. package/client/modeller/v3-palette.ts +2 -1
  8. package/client/modeller/v3-product-session.ts +8 -0
  9. package/client/modeller/v3-recipes.ts +51 -0
  10. package/client/pages/figure-v3-modeller-page.ts +25 -2
  11. package/dist-client/graphql/index.d.ts +5 -0
  12. package/dist-client/graphql/index.js +15 -0
  13. package/dist-client/graphql/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 +11 -1
  18. package/dist-client/modeller/figure-v3-links.js +69 -8
  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-parts.d.ts +15 -1
  22. package/dist-client/modeller/figure-v3-parts.js +166 -1
  23. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  24. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  25. package/dist-client/modeller/v3-clips.d.ts +1 -1
  26. package/dist-client/modeller/v3-editor.d.ts +26 -0
  27. package/dist-client/modeller/v3-editor.js +147 -4
  28. package/dist-client/modeller/v3-editor.js.map +1 -1
  29. package/dist-client/modeller/v3-gizmo.d.ts +6 -0
  30. package/dist-client/modeller/v3-gizmo.js +20 -2
  31. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  32. package/dist-client/modeller/v3-links.d.ts +27 -0
  33. package/dist-client/modeller/v3-links.js +68 -2
  34. package/dist-client/modeller/v3-links.js.map +1 -1
  35. package/dist-client/modeller/v3-palette.js +2 -1
  36. package/dist-client/modeller/v3-palette.js.map +1 -1
  37. package/dist-client/modeller/v3-product-session.d.ts +34 -0
  38. package/dist-client/modeller/v3-product-session.js +8 -0
  39. package/dist-client/modeller/v3-product-session.js.map +1 -1
  40. package/dist-client/modeller/v3-recipes.d.ts +16 -0
  41. package/dist-client/modeller/v3-recipes.js +51 -0
  42. package/dist-client/modeller/v3-recipes.js.map +1 -0
  43. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  44. package/dist-client/pages/figure-v3-modeller-page.js +29 -2
  45. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  46. package/dist-client/route.d.ts +1 -1
  47. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  48. package/dist-client/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +5 -5
  50. package/test/browser/modeller-page.js +15 -1
  51. package/test/browser/modeller.check.mjs +217 -2
  52. package/test/modeller-route.test.ts +9 -0
  53. package/test/v3-gizmo.test.ts +44 -0
  54. package/test/v3-links.test.ts +91 -1
  55. package/test/v3-recipes.test.ts +44 -0
  56. package/translations/en.json +18 -8
  57. package/translations/ja.json +18 -8
  58. package/translations/ko.json +18 -8
  59. package/translations/ms.json +18 -8
  60. package/translations/zh.json +18 -8
@@ -1,4 +1,4 @@
1
- import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Asset, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
2
  import type { V3SizeRange } from '@hatiolab/figure-model'
3
3
  import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
@@ -122,7 +122,8 @@ export function canFollow(asset: V3Asset, part: string): boolean {
122
122
  export function followableAxes(asset: V3Asset, part: string): Axis[] {
123
123
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
124
124
  if (!shape) return []
125
- if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1') return [...AXES]
125
+ /* A door frame's first three measures are its width, height and depth, as a box's (figure-model portal-profile@2). */
126
+ if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1' || shape.op === 'portal-profile@2') return [...AXES]
126
127
  if (shape.op === 'cylinder-shape@1') return ['x', 'y']
127
128
  return []
128
129
  }
@@ -210,6 +211,74 @@ export function alignAxesOf(asset: V3Asset, part: string, target: { part: string
210
211
  return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
211
212
  }
212
213
 
214
+ /** The controls a part's showing can follow: the figure's state inputs, with their range and label. */
215
+ export function visibilityControlsOf(asset: V3Asset): Array<{ id: string; unit: string; min: number; max: number; label: string }> {
216
+ const labels = (asset as unknown as { stateInputs?: Record<string, { label?: string }> }).stateInputs ?? {}
217
+ return modelOf(asset)
218
+ .inputs.filter(input => input.role === 'state')
219
+ .map(input => ({ id: input.id, unit: input.unit ?? '', min: input.min ?? 0, max: input.max ?? 1, label: labels[input.id]?.label || input.id }))
220
+ }
221
+
222
+ /** When the part is drawn (figure-model `set-visibility`): always (null), or while a control is at least a value. */
223
+ export function shownWhenOf(asset: V3Asset, part: string): { input: string; atLeast: number } | null {
224
+ const look = asset.appearance.find(one => one.target === part) as { visibility?: string } | undefined
225
+ if (!look?.visibility) return null
226
+ const m = modelOf(asset)
227
+ const node = m.nodes.find(n => n.op === 'at-least@1' && Object.values(n.outputs).includes(look.visibility!))
228
+ const at = node && m.constants.find(c => c.id === node.args[1])
229
+ return node && at ? { input: node.args[0]!, atLeast: at.value } : null
230
+ }
231
+
232
+ /** Whether `part` hangs from `from`, through any number of fastenings. */
233
+ export function hangsFrom(asset: V3Asset, part: string, from: string): boolean {
234
+ const seen = new Set<string>()
235
+ for (let at: string | undefined = part; at && !seen.has(at); ) {
236
+ seen.add(at)
237
+ const j = ((asset.joints ?? []) as V3Joint[]).find(o => o.body1.part === at)
238
+ at = j && 'part' in j.body0 ? (j.body0 as { part: string }).part : undefined
239
+ if (at === from) return true
240
+ }
241
+ return false
242
+ }
243
+
244
+ /**
245
+ * The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
246
+ * from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
247
+ * its frame slid into it (2026-09-26). Left centred where it already is, and where the target stands turned.
248
+ */
249
+ export function placeKeptOf(
250
+ asset: V3Asset,
251
+ part: string,
252
+ target: { part: string; face: string } | 'plane',
253
+ twisted = false
254
+ ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>> } {
255
+ const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>> }
256
+ let geometry
257
+ try {
258
+ geometry = compileV3Asset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
259
+ } catch {
260
+ return kept
261
+ }
262
+ const poseOf = (id: string) => geometry.find(g => g.id === id)?.pose
263
+ const mine = poseOf(part)
264
+ if (!mine) return kept
265
+ let centre = [0, 0, 0]
266
+ if (target !== 'plane') {
267
+ const theirs = poseOf(target.part)
268
+ const upright = theirs?.r.every((row, i) => row.every((v, j) => Math.abs(v - (i === j ? 1 : 0)) < 1e-9))
269
+ if (!theirs || !upright) return kept
270
+ centre = theirs.t
271
+ }
272
+ for (const { axis } of alignAxesOf(asset, part, target, twisted)) {
273
+ const k = AXES.indexOf(axis)
274
+ const off = Math.round((mine.t[k]! - centre[k]!) * 10) / 10
275
+ if (Math.abs(off) < 0.5) continue
276
+ kept.align[axis] = 'mm'
277
+ kept.alignMm[axis] = off
278
+ }
279
+ return kept
280
+ }
281
+
213
282
  /** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
214
283
  export function alignOf(
215
284
  asset: V3Asset,
@@ -39,7 +39,8 @@ export function paletteRecipe(kind: V3PaletteKind, id: string): V3PrimitiveCreat
39
39
  id,
40
40
  position: { x: 0, y: 0, z: 0 },
41
41
  kind: 'portal',
42
- dimensions: { left: -100, right: 100, bottom: 0, top: 200, openingLeft: -70, openingRight: 70, openingTop: 170, depth: 30 }
42
+ /* A door frame at a door's size, as a wall is a wall's: its width, height, depth and the jambs' and head's thickness. */
43
+ dimensions: { width: 1000, height: 2200, depth: 150, jamb: 100, head: 100 }
43
44
  }
44
45
  }
45
46
  }
@@ -120,6 +120,14 @@ export function createV3ProductSession(store: V3ProductStore) {
120
120
  writable()
121
121
  return workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps })
122
122
  },
123
+ /**
124
+ * A recipe put in by the person (ADR-0095): its resolved commands, as one step of the history, on the version open
125
+ * now. The same path as a proposal; the person is its origin.
126
+ */
127
+ applyRecipe(steps: readonly V3ProposalStep[]) {
128
+ const at = writable()
129
+ return workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps })
130
+ },
123
131
  add(part: V3PrimitiveCreation) {
124
132
  return workspace.addPrimitive({ ...writable(), part })
125
133
  },
@@ -0,0 +1,51 @@
1
+ import type { V3Recipe, V3RecipeParam, V3RecipeValue } from '@hatiolab/figure-model'
2
+
3
+ /*
4
+ * The recipe section's rules (ADR-0095), kept apart from the element so they can be tested without a browser.
5
+ */
6
+
7
+ /** A recipe's words in the language on screen: that language, else English, else the first it has. */
8
+ export function recipeWord(words: Record<string, string> | undefined, language: string): string {
9
+ if (!words) return ''
10
+ return words[language] ?? words[language.split('-')[0]!] ?? words.en ?? Object.values(words)[0] ?? ''
11
+ }
12
+
13
+ /** The recipes as the server gave them, parsed; a row that is not a recipe is left out rather than breaking the list. */
14
+ export function recipesOf(rows: ReadonlyArray<{ source: string }>): V3Recipe[] {
15
+ const out: V3Recipe[] = []
16
+ for (const row of rows) {
17
+ try {
18
+ const recipe = JSON.parse(row.source)
19
+ if (recipe && typeof recipe.name === 'string' && Array.isArray(recipe.steps)) out.push(recipe)
20
+ } catch {
21
+ /* not a recipe */
22
+ }
23
+ }
24
+ return out
25
+ }
26
+
27
+ /** Whether a recipe can be put in now: one for a part needs a part chosen. */
28
+ export function recipeReady(recipe: Pick<V3Recipe, 'target'>, selected: string): boolean {
29
+ return recipe.target === 'figure' || !!selected
30
+ }
31
+
32
+ /**
33
+ * A field's value as the recipe takes it. A ratio is shown and typed as a percentage (35 for 0.35), since that is how a
34
+ * person says a share; the rest are taken as they are. A number field left empty or not a number keeps the default.
35
+ */
36
+ export function recipeValueOf(param: V3RecipeParam, typed: string | boolean): V3RecipeValue {
37
+ if (param.kind === 'flag') return typed === true || typed === 'true'
38
+ if (param.kind === 'ratio' || param.kind === 'mm') {
39
+ const n = typeof typed === 'string' && typed.trim() !== '' ? Number(typed) : NaN
40
+ if (!Number.isFinite(n)) return param.default
41
+ return param.kind === 'ratio' ? Math.round(n * 1e4) / 1e6 : n
42
+ }
43
+ const option = (param.options ?? []).find(o => String(o) === String(typed))
44
+ return option ?? param.default
45
+ }
46
+
47
+ /** What a field shows for a value: a ratio as a percentage. */
48
+ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): string {
49
+ if (param.kind === 'ratio' && typeof value === 'number') return String(Math.round(value * 1e6) / 1e4)
50
+ return String(value)
51
+ }
@@ -5,7 +5,9 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
5
5
  import { createV3ProductSession } from '../modeller/v3-product-session.js'
6
6
  import { v3ProductStore } from '../graphql/v3-store.js'
7
7
  import { i18next } from '@operato/i18n'
8
- import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
8
+ import { fetchFigure, fetchFigureRecipes, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
9
+ import { recipesOf } from '../modeller/v3-recipes.js'
10
+ import type { V3Recipe } from '@hatiolab/figure-model'
9
11
  import { occupancyFromParts } from '../modeller/v3-occupancy.js'
10
12
  import type { V3AuthoringAction } from '@hatiolab/figure-model'
11
13
  import '../modeller/v3-editor.js'
@@ -93,6 +95,9 @@ export class FigureV3ModellerPage extends PageView {
93
95
  private routeId: string | undefined
94
96
  private discardApproved = false
95
97
  @state() private revision = 0
98
+ /** The recipes this domain sees (ADR-0095), read once when the page is first shown. */
99
+ @state() private recipes: V3Recipe[] = []
100
+ private recipesRead = false
96
101
  @state() private error = ''
97
102
  /**
98
103
  * 발행 패널이 읽는 행의 사실 — 상태와 발행 판 번호. 원본은 세션이 들고, 이 둘은 행에 있다.
@@ -328,13 +333,24 @@ export class FigureV3ModellerPage extends PageView {
328
333
  detail.error = error instanceof Error ? error.message : String(error)
329
334
  }
330
335
  }
336
+ /*
337
+ Reloading or closing the tab is not a move the shell asks `canDeactivate` about: a new figure not yet saved was lost
338
+ without a word (2026-09-26). The browser's own question is the only one it allows there.
339
+ */
340
+ private holdUnsaved = (event: BeforeUnloadEvent) => {
341
+ if (!this.active || !this.session.read().dirty) return
342
+ event.preventDefault()
343
+ event.returnValue = ''
344
+ }
331
345
  connectedCallback() {
332
346
  super.connectedCallback()
333
347
  window.addEventListener('figure-v3-apply', this.applyAI)
348
+ window.addEventListener('beforeunload', this.holdUnsaved)
334
349
  }
335
350
  disconnectedCallback() {
336
351
  this.withdrawAssistant()
337
352
  window.removeEventListener('figure-v3-apply', this.applyAI)
353
+ window.removeEventListener('beforeunload', this.holdUnsaved)
338
354
  super.disconnectedCallback()
339
355
  }
340
356
  async canDeactivate() {
@@ -360,6 +376,13 @@ export class FigureV3ModellerPage extends PageView {
360
376
  this.withdrawAssistant()
361
377
  return
362
378
  }
379
+ if (!this.recipesRead) {
380
+ this.recipesRead = true
381
+ /* A recipe list that cannot be read leaves the section out; the modeller works without it. */
382
+ fetchFigureRecipes()
383
+ .then(rows => (this.recipes = recipesOf(rows)))
384
+ .catch(() => (this.recipesRead = false))
385
+ }
363
386
  const id = lifecycle?.resourceId
364
387
  if (this.session.read().context && id === this.routeId && !changes.active) return
365
388
  try {
@@ -424,7 +447,7 @@ export class FigureV3ModellerPage extends PageView {
424
447
  render() {
425
448
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
426
449
  const view = this.session.read()
427
- return html`<figure-v3-editor .session=${this.session} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
450
+ return html`<figure-v3-editor .session=${this.session} .recipes=${this.recipes} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
428
451
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
429
452
  <figure-v3-properties
430
453
  slot="figure"
@@ -65,3 +65,8 @@ export declare function inspectFigureDraft(source: string, id?: string): Promise
65
65
  */
66
66
  export declare function fetchReleasedSource(id: string): Promise<string | undefined>;
67
67
  export declare function fetchFigureVersions(id: string): Promise<FigureVersion[]>;
68
+ /** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
69
+ export declare function fetchFigureRecipes(): Promise<Array<{
70
+ name: string;
71
+ source: string;
72
+ }>>;
@@ -283,4 +283,19 @@ export async function fetchFigureVersions(id) {
283
283
  });
284
284
  return unwrap(response, 'figureVersions') ?? [];
285
285
  }
286
+ /** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
287
+ export async function fetchFigureRecipes() {
288
+ const response = await client.query({
289
+ query: gql `
290
+ query {
291
+ figureRecipes {
292
+ name
293
+ source
294
+ }
295
+ }
296
+ `,
297
+ fetchPolicy: 'network-only'
298
+ });
299
+ return unwrap(response, 'figureRecipes') ?? [];
300
+ }
286
301
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/**\n * 편집 중인 원본의 발행 판정 — 저장하지 않은 것을 잰다.\n *\n * 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는\n * 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —\n * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.\n */\nexport async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigureDraft($source: String!, $id: String) {\n inspectFigureDraft(source: $source, id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { source, id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigureDraft')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\n/**\n * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.\n *\n * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장\n * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.\n */\nexport async function fetchReleasedSource(id: string): Promise<string | undefined> {\n const response = await client.query({\n query: gql`\n query FigureReleased($id: String!) {\n figureReleased(id: $id) {\n version\n source\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined\n}\n\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/**\n * 편집 중인 원본의 발행 판정 — 저장하지 않은 것을 잰다.\n *\n * 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는\n * 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —\n * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.\n */\nexport async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigureDraft($source: String!, $id: String) {\n inspectFigureDraft(source: $source, id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { source, id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigureDraft')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\n/**\n * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.\n *\n * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장\n * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.\n */\nexport async function fetchReleasedSource(id: string): Promise<string | undefined> {\n const response = await client.query({\n query: gql`\n query FigureReleased($id: String!) {\n figureReleased(id: $id) {\n version\n source\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined\n}\n\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n\n/** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */\nexport async function fetchFigureRecipes(): Promise<Array<{ name: string; source: string }>> {\n const response = await client.query({\n query: gql`\n query {\n figureRecipes {\n name\n source\n }\n }\n `,\n fetchPolicy: 'network-only'\n })\n return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []\n}\n"]}
@@ -15,7 +15,7 @@ export declare class FigureV3Capability extends FigureV3Capability_base {
15
15
  /** The selected part, whose places are marked. */
16
16
  selected: string;
17
17
  locked: boolean;
18
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
18
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
19
19
  private setRung;
20
20
  private pick;
21
21
  }
@@ -18,7 +18,7 @@ export declare class FigureV3Clips extends FigureV3Clips_base {
18
18
  /** Where each clip is scrubbed to, in seconds. */
19
19
  private at;
20
20
  private adding;
21
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
21
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
22
22
  private renderClip;
23
23
  private renderTrack;
24
24
  private renderAdd;
@@ -22,7 +22,7 @@ export declare class FigureV3Controls extends FigureV3Controls_base {
22
22
  private open;
23
23
  /** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
24
24
  private live;
25
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
25
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
26
26
  private renderControl;
27
27
  private renderDial;
28
28
  private renderSettings;
@@ -34,7 +34,7 @@ export declare class FigureV3Links extends FigureV3Links_base {
34
34
  /** The motion form is open over the part's own motion, to change it where it is. */
35
35
  private editingMotion;
36
36
  willUpdate(changed: Map<string, unknown>): void;
37
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
37
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
38
38
  /** Where things are held on this part: a seat things rest on, or a grip things are held in. */
39
39
  private renderPlaces;
40
40
  /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
@@ -48,6 +48,11 @@ export declare class FigureV3Links extends FigureV3Links_base {
48
48
  * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
49
49
  */
50
50
  private renderPositions;
51
+ /**
52
+ * A new aim for the fastening: where on the face is filled in as where the part stands now, so fastening it keeps it
53
+ * there (`placeKeptOf`). The author changes it from there.
54
+ */
55
+ private aimAt;
51
56
  private renderFastening;
52
57
  /**
53
58
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
@@ -60,6 +65,11 @@ export declare class FigureV3Links extends FigureV3Links_base {
60
65
  * (the editor's) shows it and takes it away.
61
66
  */
62
67
  private renderRow;
68
+ /**
69
+ * When the part is drawn: always, or while a control is at least a value -- a lamp lit while the gate is open. One
70
+ * part edit (`set-visibility`), one step. Offered once the figure has a control; a motion is what makes one.
71
+ */
72
+ private renderShown;
63
73
  private renderMotion;
64
74
  private setKind;
65
75
  private command;
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from 'lit';
4
4
  import { customElement, property, state } from 'lit/decorators.js';
5
5
  import { i18next, localize } from '@operato/i18n';
6
6
  import { V3_FACES } from '@hatiolab/figure-model';
7
- import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
7
+ import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
8
8
  import { SidePanelStyles } from './side-panel-styles.js';
9
9
  import { HelpToggles } from './v3-help.js';
10
10
  const FACES = Object.keys(V3_FACES);
@@ -98,7 +98,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
98
98
  return nothing;
99
99
  if (this.section === 'motion')
100
100
  return html `<section><h3>${i18next.t('figure.label.motion')}${this.help.button('figure.text.motion-explained')}</h3>${this.help.hint('figure.text.motion-explained')}
101
- ${this.renderMotion(asset)}</section>`;
101
+ ${this.renderMotion(asset)}${this.renderShown(asset)}</section>`;
102
102
  if (this.section === 'places')
103
103
  return html `<section><h3>${i18next.t('figure.label.places-on-part', { part: this.partId })}${this.help.button('figure.text.place-explained')}</h3>${this.help.hint('figure.text.place-explained')}
104
104
  ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html `<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}</section>`;
@@ -183,7 +183,14 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
183
183
  renderDimensions(asset) {
184
184
  const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one));
185
185
  const round = followableAxes(asset, this.partId).length === 2;
186
- return followableAxes(asset, this.partId).map(axis => {
186
+ /* One step for the whole part (figure-model follow-size): the size is declared from the parts if there is none. */
187
+ const followsAll = followableAxes(asset, this.partId).every(axis => followOf(asset, this.partId, axis).of === 'instance');
188
+ const whole = followsAll
189
+ ? nothing
190
+ : html `<button act follow-size ?disabled=${this.locked} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
191
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
192
+ </button>`;
193
+ return html `${whole}${followableAxes(asset, this.partId).map(axis => {
187
194
  const now = followOf(asset, this.partId, axis);
188
195
  const value = choiceOf(now);
189
196
  /* A cylinder's two measures are its radius and its length; its X and Y name them. */
@@ -217,7 +224,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
217
224
  </label>`
218
225
  : nothing}
219
226
  </div>`;
220
- });
227
+ })}`;
221
228
  }
222
229
  /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
223
230
  followPlus(axis, now, plus) {
@@ -269,10 +276,21 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
269
276
  : nothing}
270
277
  </div>`);
271
278
  }
279
+ /**
280
+ * A new aim for the fastening: where on the face is filled in as where the part stands now, so fastening it keeps it
281
+ * there (`placeKeptOf`). The author changes it from there.
282
+ */
283
+ aimAt(asset, f) {
284
+ const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
285
+ const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
286
+ const aim = f.toPlane ? 'plane' : f.target ? { part: f.target, face: targetFace } : undefined;
287
+ this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {} }) };
288
+ }
272
289
  renderFastening(asset) {
273
290
  const held = heldBy(asset, this.partId);
274
291
  const f = this.fasten;
275
- const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
292
+ /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
293
+ const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one) && !hangsFrom(asset, one, this.partId));
276
294
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
277
295
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
278
296
  const plane = mountingPlaneOf(asset);
@@ -298,19 +316,19 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
298
316
  </select>
299
317
  </div>
300
318
  <label toggle>
301
- <input type="checkbox" .checked=${f.toPlane} @change=${(e) => (this.fasten = { ...f, toPlane: e.target.checked })} />
319
+ <input type="checkbox" .checked=${f.toPlane} @change=${(e) => this.aimAt(asset, { ...f, toPlane: e.target.checked })} />
302
320
  ${i18next.t('figure.label.stands-on-plane')}
303
321
  </label>
304
322
  ${f.toPlane
305
323
  ? nothing
306
324
  : html `<div form>
307
325
  <span>${i18next.t('figure.label.target-part')}</span>
308
- <select @change=${(e) => (this.fasten = { ...f, target: e.target.value })}>
326
+ <select @change=${(e) => this.aimAt(asset, { ...f, target: e.target.value })}>
309
327
  <option value="" ?selected=${!f.target}>—</option>
310
328
  ${targets.map(one => html `<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
311
329
  </select>
312
330
  <span>${i18next.t('figure.label.target-face')}</span>
313
- <select @change=${(e) => (this.fasten = { ...f, targetFace: e.target.value })}>
331
+ <select @change=${(e) => this.aimAt(asset, { ...f, targetFace: e.target.value })}>
314
332
  ${theirFaces.map(face => html `<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
315
333
  </select>
316
334
  <span>${i18next.t('figure.label.facing-meet')}</span>
@@ -415,6 +433,49 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
415
433
  </button>
416
434
  </section>`;
417
435
  }
436
+ /**
437
+ * When the part is drawn: always, or while a control is at least a value -- a lamp lit while the gate is open. One
438
+ * part edit (`set-visibility`), one step. Offered once the figure has a control; a motion is what makes one.
439
+ */
440
+ renderShown(asset) {
441
+ const controls = visibilityControlsOf(asset);
442
+ if (!controls.length)
443
+ return nothing;
444
+ const now = shownWhenOf(asset, this.partId);
445
+ const send = (shownWhen) => this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit: { kind: 'set-visibility', target: this.partId, shownWhen } }, bubbles: true, composed: true }));
446
+ const first = (id) => {
447
+ const c = controls.find(one => one.id === id);
448
+ return Math.round((c.min + (c.max - c.min) * 0.01) * 1000) / 1000;
449
+ };
450
+ const unit = now ? controls.find(c => c.id === now.input)?.unit : '';
451
+ return html `<div form>
452
+ <span>${i18next.t('figure.label.shown-when')}</span>
453
+ <select
454
+ ?disabled=${this.locked}
455
+ @change=${(e) => {
456
+ const id = e.target.value;
457
+ send(id ? { input: id, atLeast: now?.input === id ? now.atLeast : first(id) } : null);
458
+ }}
459
+ >
460
+ <option value="" ?selected=${!now}>${i18next.t('figure.label.shown-always')}</option>
461
+ ${controls.map(c => html `<option value=${c.id} ?selected=${now?.input === c.id}>${i18next.t('figure.label.shown-while', { control: c.label })}</option>`)}
462
+ </select>
463
+ ${now
464
+ ? html `<span>${i18next.t('figure.label.shown-at-least')}${unit ? ` (${unit})` : ''}</span>
465
+ <input
466
+ type="number"
467
+ step="any"
468
+ ?disabled=${this.locked}
469
+ .value=${String(now.atLeast)}
470
+ @change=${(e) => {
471
+ const v = e.target.valueAsNumber;
472
+ if (Number.isFinite(v))
473
+ send({ input: now.input, atLeast: v });
474
+ }}
475
+ />`
476
+ : nothing}
477
+ </div>`;
478
+ }
418
479
  renderMotion(asset) {
419
480
  const now = motionOf(asset, this.partId);
420
481
  const joint = asset.joints?.find(j => j.body1.part === this.partId);