@things-factory/figure-ui 10.1.71 → 10.1.73

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 (44) hide show
  1. package/client/modeller/figure-v3-links.ts +12 -5
  2. package/client/modeller/figure-v3-parts.ts +1 -0
  3. package/client/modeller/figure-v3-properties.ts +3 -2
  4. package/client/modeller/figure-v3-recipe-catalog.ts +22 -4
  5. package/client/modeller/v3-editor.ts +1 -1
  6. package/client/modeller/v3-figure-facts.ts +26 -2
  7. package/client/modeller/v3-links.ts +50 -0
  8. package/client/modeller/v3-recipes.ts +16 -1
  9. package/dist-client/modeller/figure-v3-links.js +9 -3
  10. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  11. package/dist-client/modeller/figure-v3-parts.js +1 -0
  12. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  13. package/dist-client/modeller/figure-v3-properties.js +3 -2
  14. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  15. package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +2 -0
  16. package/dist-client/modeller/figure-v3-recipe-catalog.js +21 -4
  17. package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -1
  18. package/dist-client/modeller/v3-editor.js +1 -1
  19. package/dist-client/modeller/v3-editor.js.map +1 -1
  20. package/dist-client/modeller/v3-figure-facts.d.ts +9 -0
  21. package/dist-client/modeller/v3-figure-facts.js +23 -2
  22. package/dist-client/modeller/v3-figure-facts.js.map +1 -1
  23. package/dist-client/modeller/v3-links.d.ts +17 -0
  24. package/dist-client/modeller/v3-links.js +38 -0
  25. package/dist-client/modeller/v3-links.js.map +1 -1
  26. package/dist-client/modeller/v3-recipes.js +21 -1
  27. package/dist-client/modeller/v3-recipes.js.map +1 -1
  28. package/dist-client/route.d.ts +1 -1
  29. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +4 -4
  32. package/test/browser/build.mjs +12 -0
  33. package/test/browser/modeller-page.js +28 -2
  34. package/test/browser/modeller.check.mjs +47 -5
  35. package/test/browser/serve.mjs +7 -1
  36. package/test/v3-fields.test.ts +2 -1
  37. package/test/v3-links.test.ts +25 -0
  38. package/test/v3-properties.test.ts +13 -0
  39. package/test/v3-recipes.test.ts +13 -1
  40. package/translations/en.json +5 -2
  41. package/translations/ja.json +4 -1
  42. package/translations/ko.json +5 -2
  43. package/translations/ms.json +4 -1
  44. package/translations/zh.json +4 -1
@@ -27,6 +27,7 @@ import {
27
27
  shownWhenOf,
28
28
  visibilityControlsOf,
29
29
  placeKeptOf,
30
+ fasteningOf,
30
31
  hasInstanceSize,
31
32
  holdsThings,
32
33
  leadersFor,
@@ -49,7 +50,7 @@ import { HelpToggles } from './v3-help.js'
49
50
  const FACES = Object.keys(V3_FACES) as V3Face[]
50
51
 
51
52
  const freshFasten = () => ({
52
- face: 'bottom' as V3Face,
53
+ face: 'bottom' as V3Face | 'centre',
53
54
  toPlane: false,
54
55
  target: '',
55
56
  targetFace: 'top' as V3Face,
@@ -131,7 +132,9 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
131
132
  */
132
133
  willUpdate(changed: Map<string, unknown>) {
133
134
  if (changed.has('partId') && changed.get('partId') !== undefined) {
134
- this.fasten = freshFasten()
135
+ /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
136
+ const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null
137
+ this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
135
138
  this.motion = freshMotion()
136
139
  this.place = freshPlace()
137
140
  this.row = freshRow()
@@ -355,8 +358,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
355
358
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
356
359
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
357
360
  const plane = mountingPlaneOf(asset)
358
- const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face) && (!f.toPlane || plane.faces.includes(face as V3Face)))
359
- const myFace = mineFaces.includes(f.face) ? f.face : (mineFaces[0] as V3Face)
361
+ /* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
362
+ const mineFaces: Array<V3Face | 'centre'> = [
363
+ ...(facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face) && (!f.toPlane || plane.faces.includes(face as V3Face))) as V3Face[]),
364
+ ...(f.toPlane ? [] : (['centre'] as const))
365
+ ]
366
+ const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0]!
360
367
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
361
368
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
362
369
  const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
@@ -372,7 +379,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
372
379
  : nothing}
373
380
  <div form>
374
381
  <span>${i18next.t('figure.label.my-face')}</span>
375
- <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
382
+ <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face | 'centre' })}>
376
383
  ${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
377
384
  </select>
378
385
  </div>
@@ -463,6 +463,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
463
463
  >
464
464
  <b>${b.grade}</b>
465
465
  <span>
466
+ <strong performance-cost>${i18next.t('figure.label.performance-cost')}</strong>
466
467
  ${i18next.t('figure.text.parts-budget', {
467
468
  triangles: b.triangles.toLocaleString(),
468
469
  groups: b.groups.toLocaleString(),
@@ -161,8 +161,9 @@ export class FigureV3Properties extends localize(i18next)(LitElement) {
161
161
 
162
162
  private renderMounting(facts: V3FigureFacts) {
163
163
  const mounting = facts.mounting
164
- const part = [this.pickedPart, mounting?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
165
- const face: V3Face = this.pickedFace ?? mounting?.face ?? 'bottom'
164
+ /* Chosen, or what is set, or where the figure meets its plane -- not the first part listed (walk 1). */
165
+ const part = [this.pickedPart, mounting?.part, facts.suggested?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
166
+ const face: V3Face = this.pickedFace ?? mounting?.face ?? facts.suggested?.face ?? 'bottom'
166
167
  return html`
167
168
  <div field>
168
169
  <label>${i18next.t('figure.label.mounting-face')}</label>
@@ -239,8 +239,23 @@ export class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
239
239
  @state() private query = ''
240
240
  @state() private chosen = ''
241
241
  @state() private values: Record<string, V3RecipeValue> = {}
242
+ /*
243
+ The guide as a page \`ox-markdown\` can read: it renders what its src points to, not text, and given the text it
244
+ fetched it as a path and drew the app's own page in its place (seen on :3300, 2026-09-26). A blob of type
245
+ text/markdown is read as markdown.
246
+ */
242
247
  @state() private guide = ''
243
248
 
249
+ private set guideText(text: string) {
250
+ if (this.guide) URL.revokeObjectURL(this.guide)
251
+ this.guide = text ? URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) : ''
252
+ }
253
+
254
+ disconnectedCallback() {
255
+ super.disconnectedCallback()
256
+ this.guideText = ''
257
+ }
258
+
244
259
  render() {
245
260
  const language = i18next.language || 'en'
246
261
  const categories = [...new Set(this.templates.map(t => t.category))].sort()
@@ -283,16 +298,19 @@ export class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
283
298
  if (this.chosen === t.name) return
284
299
  this.chosen = t.name
285
300
  this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]))
286
- this.guide = ''
301
+ this.guideText = ''
287
302
  this.guideOf?.(t.name)
288
- .then(text => this.chosen === t.name && (this.guide = text))
303
+ .then(text => this.chosen === t.name && (this.guideText = text))
289
304
  .catch(() => {})
290
305
  }
291
306
 
292
307
  protected willUpdate() {
293
- /* The first card is chosen while nothing is, so the detail is never empty. */
308
+ /*
309
+ The first card shown is chosen while none of those shown is, so the detail is never empty and never of a card the
310
+ search or the category has taken away (the air cylinder stayed told under a search for rollers, :3300, 2026-09-26).
311
+ */
294
312
  const shown = recipesMatching(this.templates, this.category, this.query)
295
- if (!this.templates.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])
313
+ if (!shown.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])
296
314
  }
297
315
 
298
316
  private renderDetail(t: RecipeTemplate, language: string) {
@@ -1228,7 +1228,7 @@ export class FigureV3Editor extends LitElement {
1228
1228
  name-field
1229
1229
  aria-label=${i18next.t('figure.label.name')}
1230
1230
  .value=${partLabelOf(this.selected)}
1231
- ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
1231
+ ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)?.index}
1232
1232
  @change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
1233
1233
  />
1234
1234
  </div>
@@ -1,4 +1,4 @@
1
- import { parseV3Asset } from '@hatiolab/figure-model'
1
+ import { compileV3Asset, parseV3Asset, v3OccupiedBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
2
2
  import type { V3Asset, V3Face } from '@hatiolab/figure-model'
3
3
 
4
4
  /*
@@ -14,6 +14,12 @@ export interface V3FigureFacts {
14
14
  declared: boolean
15
15
  placement?: Placement
16
16
  mounting?: { part: string; face: V3Face }
17
+ /**
18
+ * Where the figure meets its plane, as its parts stand: the part whose bottom is lowest for a floor, whose top is
19
+ * highest for a ceiling, whose back is furthest back for a wall. What the mounting face is offered as before one is
20
+ * chosen -- the first part in the list offered a conveyor's bed, 700 mm up on its legs (walk 1, 2026-09-26).
21
+ */
22
+ suggested?: { part: string; face: V3Face }
17
23
  }
18
24
 
19
25
  /**
@@ -33,10 +39,28 @@ export function v3FigureFacts(source: string): V3FigureFacts {
33
39
  parts,
34
40
  declared: !!asset.occupancy,
35
41
  placement: asset.occupancy?.placement as Placement | undefined,
36
- mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined
42
+ mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined,
43
+ suggested: suggestedMountOf(asset, parts)
37
44
  }
38
45
  }
39
46
 
47
+ function suggestedMountOf(asset: V3Asset, parts: string[]): { part: string; face: V3Face } | undefined {
48
+ const placement = (asset.occupancy?.placement ?? 'floor') as Placement
49
+ if (placement === 'center' || !parts.length) return undefined
50
+ let boxes
51
+ try {
52
+ boxes = v3OccupiedBoxesOf(compileV3Asset(asset).evaluate().geometry, v3WritersOf(asset)).filter(b => parts.includes(b.part))
53
+ } catch {
54
+ return undefined
55
+ }
56
+ const [axis, face, sign] = placement === 'wall' ? (['z', 'back', -1] as const) : placement === 'ceiling' ? (['y', 'top', 1] as const) : (['y', 'bottom', -1] as const)
57
+ /* The side of each box that faces the plane; the one furthest toward it, the first in the list among equals. */
58
+ const reach = (b: (typeof boxes)[number]) => sign * (b.centre[axis] + (sign * b.extent[axis]) / 2)
59
+ let best: (typeof boxes)[number] | undefined
60
+ for (const b of boxes) if (!best || reach(b) > reach(best) + 1e-6) best = b
61
+ return best ? { part: best.part, face } : undefined
62
+ }
63
+
40
64
  /**
41
65
  * A tag line as a list: split on commas, drop a leading `#`, keep each once. An empty
42
66
  * line is an empty list, so an author can clear every tag.
@@ -182,6 +182,56 @@ export function hangsFrom(asset: V3Asset, part: string, from: string): boolean {
182
182
  return false
183
183
  }
184
184
 
185
+ /**
186
+ * The fastening a part has, as the fastening form holds it -- so the form opens on what is there (the face, including
187
+ * \`centre\`, the target and its face, the gap, the twist and where across the face) and a change is a change to that.
188
+ * Opening it on the defaults showed a roller fastened by its centre as fastened by its bottom, and fastening again
189
+ * from there broke its turning in place (walk 1, 2026-09-26). Null for a part fastened to nothing.
190
+ */
191
+ export function fasteningOf(
192
+ asset: V3Asset,
193
+ part: string
194
+ ): {
195
+ face: string
196
+ toPlane: boolean
197
+ target: string
198
+ targetFace: string
199
+ facing: 'meet' | 'flush'
200
+ gap: number
201
+ twist: number
202
+ align: Partial<Record<Axis, Alignment>>
203
+ alignMm: Partial<Record<Axis, number>>
204
+ } | null {
205
+ const record = asset.joints?.find(j => j.body1.part === part)
206
+ if (!record) return null
207
+ const toPlane = 'plane' in record.body0
208
+ const target = 'part' in record.body0 ? record.body0.part : ''
209
+ const targetFace = 'part' in record.body0 ? record.body0.face : 'top'
210
+ const twist = record.origin?.twist ?? 0
211
+ /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
212
+ const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
213
+ for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
214
+ if (how === 'min' || how === 'max' || how === 'centre') {
215
+ kept.align[axis] = how
216
+ delete kept.alignMm[axis]
217
+ } else if (how && typeof how === 'object' && typeof (how as { mm?: unknown }).mm === 'number') {
218
+ kept.align[axis] = 'mm'
219
+ kept.alignMm[axis] = (how as { mm: number }).mm
220
+ }
221
+ }
222
+ const gap = record.origin?.gap
223
+ return {
224
+ face: record.body1.face,
225
+ toPlane,
226
+ target,
227
+ targetFace,
228
+ facing: record.origin?.facing ?? 'meet',
229
+ gap: typeof gap === 'number' ? gap : 0,
230
+ twist,
231
+ ...kept
232
+ }
233
+ }
234
+
185
235
  /**
186
236
  * The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
187
237
  * from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
@@ -84,9 +84,24 @@ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): strin
84
84
  /** The templates a category and words find: a word matches the title, the description, the name or a tag. */
85
85
  export function recipesMatching<T extends { category: string; title: string; description: string; name: string; tags: string[] }>(templates: readonly T[], category: string, query: string): T[] {
86
86
  const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
87
- return templates.filter(t => {
87
+ const found = templates.filter(t => {
88
88
  if (category && t.category !== category) return false
89
89
  const text = [t.title, t.description, t.name, ...t.tags].join(' ').toLowerCase()
90
90
  return words.every(w => text.includes(w))
91
91
  })
92
+ if (!words.length) return found
93
+ /*
94
+ What the search names comes first: a title that is the words, then one that starts with them, then a title word that
95
+ does, then the rest in their order. 「다리」 listed 사다리 before 다리 넷 (walk 1, 2026-09-26).
96
+ */
97
+ const rank = (t: T) => {
98
+ const title = t.title.toLowerCase()
99
+ const query = words.join(' ')
100
+ if (title === query) return 0
101
+ if (title.startsWith(query)) return 1
102
+ if (words.every(w => title.split(/\s+/).some(part => part.startsWith(w)))) return 2
103
+ if (words.every(w => title.includes(w))) return 3
104
+ return 4
105
+ }
106
+ return found.map((t, i) => ({ t, i, r: rank(t) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.t)
92
107
  }
@@ -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, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, 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, fasteningOf, 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);
@@ -85,7 +85,9 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
85
85
  */
86
86
  willUpdate(changed) {
87
87
  if (changed.has('partId') && changed.get('partId') !== undefined) {
88
- this.fasten = freshFasten();
88
+ /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
89
+ const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null;
90
+ this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
89
91
  this.motion = freshMotion();
90
92
  this.place = freshPlace();
91
93
  this.row = freshRow();
@@ -294,7 +296,11 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
294
296
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
295
297
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
296
298
  const plane = mountingPlaneOf(asset);
297
- const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face) && (!f.toPlane || plane.faces.includes(face)));
299
+ /* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
300
+ const mineFaces = [
301
+ ...facesOf(asset, this.partId).filter(face => FACES.includes(face) && (!f.toPlane || plane.faces.includes(face))),
302
+ ...(f.toPlane ? [] : ['centre'])
303
+ ];
298
304
  const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0];
299
305
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
300
306
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];