@things-factory/figure-ui 10.1.181 → 10.1.185

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 (67) hide show
  1. package/client/modeller/editor-styles.ts +28 -9
  2. package/client/modeller/editor.ts +105 -12
  3. package/client/modeller/fasten-form.ts +207 -0
  4. package/client/modeller/fasten-geometry.ts +5 -1
  5. package/client/modeller/figure-facts.ts +4 -35
  6. package/client/modeller/figure-fasten-dialog.ts +457 -0
  7. package/client/modeller/figure-fasten-drawing.ts +32 -11
  8. package/client/modeller/figure-links.ts +56 -300
  9. package/client/modeller/figure-properties.ts +23 -70
  10. package/client/modeller/modeller-keys.ts +4 -4
  11. package/dist-client/modeller/clips.d.ts +2 -2
  12. package/dist-client/modeller/editor-styles.js +28 -9
  13. package/dist-client/modeller/editor-styles.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +23 -2
  15. package/dist-client/modeller/editor.js +121 -12
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-form.d.ts +69 -0
  18. package/dist-client/modeller/fasten-form.js +131 -0
  19. package/dist-client/modeller/fasten-form.js.map +1 -0
  20. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  21. package/dist-client/modeller/fasten-geometry.js +6 -1
  22. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  23. package/dist-client/modeller/figure-facts.d.ts +2 -13
  24. package/dist-client/modeller/figure-facts.js +2 -29
  25. package/dist-client/modeller/figure-facts.js.map +1 -1
  26. package/dist-client/modeller/figure-fasten-dialog.d.ts +69 -0
  27. package/dist-client/modeller/figure-fasten-dialog.js +433 -0
  28. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  29. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  30. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  31. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  32. package/dist-client/modeller/figure-links.d.ts +14 -38
  33. package/dist-client/modeller/figure-links.js +58 -301
  34. package/dist-client/modeller/figure-links.js.map +1 -1
  35. package/dist-client/modeller/figure-properties.d.ts +12 -12
  36. package/dist-client/modeller/figure-properties.js +23 -72
  37. package/dist-client/modeller/figure-properties.js.map +1 -1
  38. package/dist-client/modeller/modeller-keys.js +4 -4
  39. package/dist-client/modeller/modeller-keys.js.map +1 -1
  40. package/dist-client/route.d.ts +1 -1
  41. package/dist-client/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +8 -8
  43. package/test/browser/links-page.js +4 -4
  44. package/test/browser/links.check.mjs +196 -46
  45. package/test/browser/modeller-page.js +23 -0
  46. package/test/browser/modeller.check.mjs +19 -20
  47. package/test/browser/namespace-page.js +2 -29
  48. package/test/browser/popup-stand-in.js +48 -0
  49. package/test/browser/product-page.check.mjs +60 -9
  50. package/test/browser/product-page.js +30 -33
  51. package/test/browser/property-section.check.mjs +3 -3
  52. package/test/browser/props.check.mjs +28 -7
  53. package/test/browser/screen-page.js +7 -2
  54. package/test/browser/screen.check.mjs +28 -23
  55. package/test/browser/section-page.js +13 -41
  56. package/test/browser/section.check.mjs +39 -30
  57. package/test/fasten-form.test.ts +77 -0
  58. package/test/fasten-geometry.test.ts +2 -0
  59. package/test/first-minute.test.ts +7 -4
  60. package/test/properties.test.ts +11 -19
  61. package/translations/en.json +5 -12
  62. package/translations/es.json +5 -12
  63. package/translations/ja.json +5 -12
  64. package/translations/ko.json +5 -12
  65. package/translations/ms.json +5 -12
  66. package/translations/zh.json +5 -12
  67. package/client/modeller/mounting-part.ts +0 -15
@@ -5,31 +5,15 @@ import '@operato/input/ox-input-select-buttons.js';
5
5
  import './figure-slot-layout-panel.js';
6
6
  import { customElement, property, state } from 'lit/decorators.js';
7
7
  import { i18next, localize } from '@operato/i18n';
8
- import { FIGURE_FACES, figureFaceOverhangOf } from '@hatiolab/figure-model';
9
- import { acceptsOf, alignOf, AXES, canFasten, facesOf, canFollow, followCommandOf, followableAxes, positionIsShare, choiceOf, followWordsOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, fasteningOf, fastenGapOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionFormRefusalOf, endPlaceKeyOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, rowBlockOf, fixedJointOf, rowOf, positionsWorkedOut, fieldWordsOf, measureNow, ownFacesOf, alignRowsOf } from './links.js';
8
+ import { figureFaceOverhangOf } from '@hatiolab/figure-model';
9
+ import { acceptsOf, AXES, canFasten, canFollow, followCommandOf, followableAxes, positionIsShare, choiceOf, followWordsOf, heldBy, followOf, shownWhenOf, visibilityControlsOf, fasteningOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionFormRefusalOf, endPlaceKeyOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, rowBlockOf, fixedJointOf, rowOf, positionsWorkedOut, measureNow } from './links.js';
10
10
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js';
11
- import './figure-fasten-drawing.js';
12
- import { faceViewOf, insideAlignmentOf, modeOf, myFaceFor, turnsInPlace } from './fasten-geometry.js';
11
+ import { insideAlignmentOf } from './fasten-geometry.js';
12
+ import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js';
13
13
  import { HelpToggles } from './help.js';
14
14
  import { groupMenu, GroupMenuStyles } from './group-menu.js';
15
15
  import { jointAxisWord } from './joint-axis.js';
16
16
  import { placeTerms } from '@things-factory/figure-viewer/dist-client/detail-facts.js';
17
- const FACES = Object.keys(FIGURE_FACES);
18
- /** The target list's entry for the plane the figure stands by. */
19
- const PLANE = '__plane';
20
- const freshFasten = () => ({
21
- face: 'bottom',
22
- toPlane: false,
23
- target: '',
24
- targetFace: 'top',
25
- facing: 'meet',
26
- gap: 0,
27
- gapKept: undefined,
28
- twist: 0,
29
- align: {},
30
- alignMm: {},
31
- alignInput: {}
32
- });
33
17
  const freshMotion = () => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 });
34
18
  const freshRow = () => ({ axis: '', pitch: 200, fills: true, count: 5 });
35
19
  const freshPlace = () => ({ role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 });
@@ -43,7 +27,8 @@ const freshPlace = () => ({ role: 'grip', face: 'top', label: '', accepts: '', c
43
27
  *
44
28
  * The sections are dimensions, placement and joint, in that order. Every
45
29
  * change is one figure-model authoring command, sent as `command` and run by the editor through the session — one
46
- * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
30
+ * step of the undo history. The fastening is said here in one line; it is chosen in the fastening window
31
+ * (`figure-fasten-dialog.ts`), which the editor opens when this asks (`fasten-open`). Choices the command would refuse (a round body fastened, a size following an instance
47
32
  * size the figure does not declare) are not offered, so the author is not sent into a refusal.
48
33
  */
49
34
  let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
@@ -56,11 +41,12 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
56
41
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
57
42
  this.section = 'shape';
58
43
  /**
59
- * Its tab is the one shown. Two sections speak of what a command would draw -- the properties' fastening and the
60
- * response's row -- and only the one in sight may: the hidden one's silence would clear what the shown one draws.
44
+ * Its tab is the one shown, and no window over it speaks instead. The response's row speaks of what a command would
45
+ * draw, and the properties' says there is none: only the one in sight may, as the hidden one's silence would clear what
46
+ * the shown one draws. While the fastening window is open it alone speaks (the editor sets this false).
61
47
  */
62
48
  this.active = true;
63
- /** What the fastening command would do, in words (the editor works it out: `command-preview.ts`). */
49
+ /** What the row's command would do, in words (the editor works it out: `command-preview.ts`). */
64
50
  this.previewWords = [];
65
51
  /** Where the selected part's motion takes it, at both ends of its range (the editor draws them on the canvas). */
66
52
  this.motionEnds = null;
@@ -71,15 +57,7 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
71
57
  /** What taking this part off would do, said beside the buttons that do it (worked out on a copy by the editor). */
72
58
  this.detachWords = [];
73
59
  this.told = '';
74
- /* Two forms describe a command here: the fastening and the row. The one being worked on is the one seen. */
75
- this.working = 'fasten';
76
- /** A new fastening starts with a target. Its face and alignment are asked only after that target is chosen. */
77
- this.choosingFastening = false;
78
- /** The fastening the part has, opened to be changed: until then the place group says it in one line. */
79
- this.changingFastening = false;
80
- /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
81
- this.spotlit = false;
82
- this.fasten = freshFasten();
60
+ this.rowOpen = false;
83
61
  /** Why 정렬·간격 고치기 moved nothing, said under it; empty once it moved the part or the part changed. */
84
62
  this.fixNote = '';
85
63
  this.motion = freshMotion();
@@ -134,7 +112,6 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
134
112
  details[resize-choice] { margin: 0; padding: 0; border: 0; }
135
113
  details[resize-choice] > div { display: grid; gap: 8px; padding: 8px 0; }
136
114
  p[fasten-mode] { margin: 0; font-weight: 600; }
137
- section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
138
115
  details { margin-top: 8px; }
139
116
  details[row-fold] { margin: 0; }
140
117
  details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
@@ -148,7 +125,6 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
148
125
  div[overhang] { display: grid; gap: 6px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--md-sys-color-outline-variant, #8884); }
149
126
  div[overhang] p { margin: 0; }
150
127
  ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; }
151
- span[fasten-summary] { opacity: 0.8; }
152
128
  /* The difference a following measure keeps: under its choice, on the same line as its words. */
153
129
  label[inline-plus] {
154
130
  display: flex;
@@ -172,15 +148,10 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
172
148
  this.fixNote = '';
173
149
  if (this.asset && this.partId && this.partId !== this.loadedFor) {
174
150
  this.loadedFor = this.partId;
175
- /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
176
- const own = this.revision && this.partId ? fasteningOf(this.revision, this.partId) : null;
177
- this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
178
151
  this.motion = freshMotion();
179
152
  this.place = freshPlace();
180
153
  this.row = freshRow();
181
154
  this.editingMotion = false;
182
- this.choosingFastening = false;
183
- this.changingFastening = false;
184
155
  }
185
156
  }
186
157
  updated() {
@@ -260,7 +231,7 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
260
231
  : nothing}
261
232
  ${rowOf(asset, this.partId) ? html `<slot name="row"></slot>` : this.renderRow(asset)}
262
233
  `;
263
- this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : undefined;
234
+ this.candidate = this.rowOpen && this.rowCandidate ? { action: this.rowCandidate } : undefined;
264
235
  return response;
265
236
  }
266
237
  const shown = html `
@@ -268,7 +239,7 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
268
239
  <h3>${i18next.t('figure.label.group-size')}</h3>
269
240
  <slot name="size"></slot>
270
241
  </section>
271
- <section fastening ?spotlit=${this.spotlit} @focusin=${() => (this.working = 'fasten')}>
242
+ <section fastening>
272
243
  <h3>
273
244
  ${i18next.t('figure.label.group-place')}${this.help.button('figure.text.fastening-explained')}
274
245
  ${groupMenu([{ label: i18next.t('figure.button.detach'), icon: 'link_off', disabled: this.locked || !held, run: () => this.command({ kind: 'detach', part: this.partId }) }])}
@@ -412,50 +383,36 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
412
383
  </div>`;
413
384
  })}</div></details>`;
414
385
  }
415
- /**
416
- * "Open attachment settings": the fastening this part has now -- its face, what it is on, the gap -- in the form below,
417
- * brought into view with the focus on it. The form is loaded again first: the part was fastened by "follow size,
418
- * attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
419
- * holding places instead).
420
- */
421
- /** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
422
386
  /**
423
387
  * 정렬·간격 고치기: the part moved inside the face it is fastened on, as one step -- the least move on each side it is
424
388
  * out, or centred when it is wider than the face. When nothing of it can be moved, why is said under the button, not
425
- * nothing at all (:3300, 2026-10-11: the button only scrolled to the drawing, which was already in view).
389
+ * nothing at all (:3300, 2026-10-11: the button only scrolled to the drawing, which was already in view). The command
390
+ * is the fastening window's for the same choice (`fasten-form.ts`), with only the place across the face changed.
426
391
  */
427
- async fixAlignment(view, target) {
392
+ fixAlignment() {
393
+ const revision = this.revision;
394
+ if (!revision)
395
+ return;
396
+ const form = fastenFormOf(revision, this.partId);
428
397
  /* Whether it is out is the kernel's measure (a round face is not measured there); the drawing's sizes say how far to move. */
429
- const own = this.revision ? figureFaceOverhangOf(this.revision.asset, this.partId, this.revision.inputs) : undefined;
398
+ const own = figureFaceOverhangOf(revision.asset, this.partId, revision.inputs);
430
399
  const fixed = own?.state === 'unknown'
431
400
  ? { cannot: 'unmeasured' }
432
401
  : own?.state === 'out' || own?.state === 'possible'
433
- ? insideAlignmentOf(view, this.fasten.align, this.fasten.alignMm)
402
+ ? insideAlignmentOf(fastenPlanOf(revision, this.partId, form).view, form.align, form.alignMm)
434
403
  : { cannot: 'inside' };
435
404
  if ('cannot' in fixed) {
436
- this.fixNote = i18next.t(`figure.text.fix-alignment-${fixed.cannot}`, { part: this.partId, on: target });
405
+ this.fixNote = i18next.t(`figure.text.fix-alignment-${fixed.cannot}`, { part: this.partId, on: form.target });
437
406
  return;
438
407
  }
439
408
  this.fixNote = '';
440
- this.fasten = { ...this.fasten, align: fixed.align, alignMm: fixed.alignMm };
441
- await this.onFastenRelease(true);
442
- }
443
- /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
444
- async showFastening() {
445
- if (this.asset)
446
- await this.openFastening(this.asset);
409
+ const action = fastenPlanOf(revision, this.partId, { ...form, align: fixed.align, alignMm: fixed.alignMm }).action;
410
+ if (action)
411
+ this.command(action);
447
412
  }
448
- async openFastening(asset) {
449
- const own = fasteningOf(this.revision, this.partId);
450
- this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
451
- /* Asked for from elsewhere: the form itself, not the one-line summary of the fastening it has. */
452
- this.changingFastening = true;
453
- this.spotlit = true;
454
- await this.updateComplete;
455
- const section = this.renderRoot.querySelector('section[fastening]');
456
- section?.scrollIntoView({ block: 'start', behavior: 'smooth' });
457
- (section?.querySelector('select, input') ?? section?.querySelector('button'))?.focus({ preventScroll: true });
458
- setTimeout(() => (this.spotlit = false), 1600);
413
+ /** The fastening window, asked of the editor: it opens over the canvas on what the part has now. */
414
+ openFastening() {
415
+ this.dispatchEvent(new CustomEvent('fasten-open', { bubbles: true, composed: true }));
459
416
  }
460
417
  /**
461
418
  * What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
@@ -508,245 +465,61 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
508
465
  </div>`);
509
466
  }
510
467
  /**
511
- * A new aim for the fastening: where on the face is filled in as where the part stands now, so fastening it keeps it
512
- * there (`placeKeptOf`). The author changes it from there.
468
+ * The fastening in one line -- what, on which face, how -- and 「바꾸기」 to open the window where it is chosen (chief
469
+ * architect 2026-10-16; user 2026-10-17: the result here, the editing in a window with room). Taking it off is in the
470
+ * group's 「⋯」. What hangs over the face, and the way to fix it, stay here: they are about the fastening as it is now.
513
471
  */
514
- aimAt(asset, f) {
515
- const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
516
- const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
517
- const aim = f.toPlane ? 'plane' : f.target ? { part: f.target, face: targetFace } : undefined;
518
- this.fasten = { ...f, ...(aim ? placeKeptOf(this.revision, this.partId, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) };
519
- }
520
472
  renderFastening(asset) {
521
- const held = heldBy(asset, this.partId);
522
- /* Placed by an origin (ADR-0107 decision 4): attach writes faces, so the form would move it off its origin. Said, and taken off only. */
523
- if (fasteningOf(this.revision, this.partId)?.atOrigin) {
524
- this.fastenCandidate = undefined;
473
+ const revision = this.revision;
474
+ const own = fasteningOf(revision, this.partId);
475
+ /* Placed by an origin (ADR-0107 decision 4): attach writes faces, so the window would move it off its origin. Said, and taken off only. */
476
+ if (own?.atOrigin)
525
477
  return html `<div fastened fasten-at-origin>
526
478
  <p>${this.heldWords(asset)}</p>
527
479
  ${this.renderDetachWords()}
528
480
  </div>`;
529
- }
530
- const f = this.fasten;
531
- /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
532
- const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
533
- /* Shown, not dropped, and not choosable, with the reason (ADR-0097 §5.5): fastening it would close a loop. */
534
- const loops = (one) => hangsFrom(asset, one, this.partId);
535
- const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'));
536
- /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
537
- const mineFaces = ownFacesOf(asset, this.partId, f.toPlane);
538
- const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
539
- const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
540
- const aim = f.toPlane ? 'plane' : { part: f.target, face: targetFace };
541
- /* The part's own face is not chosen: the target's face and how give it (user 2026-10-01; the kernel takes no other). */
542
- const mode = f.toPlane ? 'meet' : modeOf(f.face, f.facing);
543
- const derived = f.toPlane || f.target ? myFaceFor(this.revision, this.partId, aim, mode) : undefined;
544
- const myFace = derived ?? (mineFaces.includes(f.face) ? f.face : mineFaces[0]);
545
- const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist, f.alignInput) : undefined;
546
- const action = f.toPlane || f.target
547
- ? {
548
- kind: 'attach',
549
- part: this.partId,
550
- face: myFace,
551
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
552
- ...(f.toPlane ? {} : { facing: f.facing }),
553
- ...fastenGapOf(f),
554
- twist: f.twist,
555
- ...(align ? { align } : {}),
556
- replace: !!held
557
- }
558
- : undefined;
559
- if (action)
560
- this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) };
561
- /*
562
- What the folded details hold that is not their default, said while folded; the way and the place on the face are
563
- the drawing's, and said there (user 2026-10-02: the details had selects repeating the drawing). Nothing is said
564
- for the default.
565
- */
566
- const detail = [
567
- ...(f.gapKept !== undefined
568
- ? [i18next.t('figure.text.fasten-summary-gap-worked-out')]
569
- : f.gap
570
- ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })]
571
- : []),
572
- ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
573
- ];
574
- /* Across the face, a row only where the drawing cannot set it: no drawing of the face, or a share or a field to follow. */
575
- const view = f.toPlane || f.target ? faceViewOf(this.revision, this.partId, aim) : undefined;
576
- const alignRows = f.toPlane || f.target ? alignRowsOf(asset, this.partId, aim, view, !!f.twist, f.alignInput) : [];
577
- /* A fastening the part has is changed as soon as something is chosen, as the drawing does; a new one waits for 붙이기. */
578
- const settle = () => this.onFastenRelease(!!held);
579
- /*
580
- Fastening it anew is said, so the form is not taken for the one it has (designer 2026-09-27). The one it has is
581
- said once, by the sentence over the drawing -- the panel's head says it too; a third and a fourth line saying the
582
- same (「현재 관계 수정」, 「붙은 곳」) went (user 2026-10-01, the panel checked for balance).
583
- */
584
- const current = asset.joints?.find(j => j.body1.part === this.partId);
585
- const editing = i18next.t('figure.text.fasten-new');
586
- /*
587
- A fastening the part has, said in one line -- what, on which face, how -- and 「바꾸기」 to open the drawing and the
588
- details (chief architect 2026-10-16). They were open on every fastened part, near a screen of the panel, for a
589
- choice made once. Taking it off is in the group's 「⋯」. What would hang over still shows: it is about now.
590
- */
591
- /* What hangs over the face, and the way to fix it: about the fastening as it is now, so said open or folded. */
592
481
  const overhang = html `${this.overhangNote.length ? html `<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
593
482
  ${this.overhangWords.length
594
483
  ? html `<div overhang role="status">
595
484
  ${this.overhangWords.map(line => html `<p>${line}</p>`)}
596
- ${current
597
- ? html `<button act fix-alignment ?disabled=${this.locked} @click=${() => this.fixAlignment(view, held ? f.target : '')}>
485
+ ${own
486
+ ? html `<button act fix-alignment ?disabled=${this.locked} @click=${() => this.fixAlignment()}>
598
487
  <md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}
599
488
  </button>
600
489
  ${this.fixNote ? html `<p hint fix-note>${this.fixNote}</p>` : nothing}`
601
490
  : nothing}
602
491
  </div>`
603
492
  : nothing}`;
604
- if (current && held && !this.changingFastening && (f.toPlane || f.target))
493
+ if (own && heldBy(asset, this.partId)) {
494
+ const form = fastenFormOf(revision, this.partId);
495
+ const plan = fastenPlanOf(revision, this.partId, form);
496
+ if (plan.action)
497
+ this.fastenCandidate = { action: plan.action, ...(plan.face ? { face: plan.face } : {}) };
498
+ const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'));
605
499
  return html `
606
500
  ${overhang}
607
501
  <div fastened-summary>
608
- <p fasten-sentence>${this.sentenceOf(myFace, f.toPlane ? planeWord : `${f.target}의 ${i18next.t('figure.label.face-' + targetFace)}`, mode)}</p>
609
- <button act change-fastening ?disabled=${this.locked} @click=${() => (this.changingFastening = true)}>${i18next.t('figure.button.change-fastening')}</button>
502
+ <p fasten-sentence>${fastenSentenceOf(this.partId, form, plan, planeWord, (key, params) => i18next.t(key, params))}</p>
503
+ <button act change-fastening ?disabled=${this.locked} @click=${() => this.openFastening()}>${i18next.t('figure.button.change-fastening')}</button>
610
504
  </div>
611
505
  ${this.renderDetachWords()}
612
506
  `;
613
- /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
614
- if (!current && !this.choosingFastening && !f.target)
615
- return html `
616
- <p hint>${i18next.t('figure.text.fasten-start')}</p>
617
- <button act ?disabled=${this.locked} @click=${() => (this.choosingFastening = true)}>
618
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
619
- </button>
620
- `;
507
+ }
508
+ /* A blank attachment is not a form to decipher: what it does, and the way to the window. */
621
509
  return html `
622
- ${current ? nothing : html `<p fasten-mode>${editing}</p>`}
623
510
  ${overhang}
624
- ${held ? this.renderDetachWords() : nothing}
625
- <div form>
626
- <span>${i18next.t('figure.label.target-part')}</span>
627
- <select @change=${(e) => {
628
- const value = e.target.value;
629
- this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value });
630
- if (value)
631
- settle();
632
- }}>
633
- <option value="" ?selected=${!f.target && !f.toPlane}>${i18next.t('figure.label.target-part-placeholder')}</option>
634
- <option value=${PLANE} ?selected=${f.toPlane}>${planeWord}</option>
635
- ${targets.map(one => loops(one)
636
- ? html `<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
637
- : html `<option value=${one} ?selected=${one === f.target && !f.toPlane}>${one}</option>`)}
638
- </select>
639
- </div>
640
- ${f.toPlane || f.target
641
- ? html `<p fasten-sentence>${this.sentenceOf(myFace, f.toPlane ? planeWord : `${f.target}의 ${i18next.t('figure.label.face-' + targetFace)}`, mode)}</p>
642
- <figure-fasten-drawing
643
- .target=${f.toPlane ? '' : f.target}
644
- .planeWord=${planeWord}
645
- .faces=${theirFaces}
646
- .face=${targetFace}
647
- .modes=${f.toPlane ? ['meet'] : ([...['meet', 'flush'], ...(turnsInPlace(asset, this.partId) ? ['centre'] : [])])}
648
- .mode=${mode}
649
- .view=${view}
650
- .align=${f.align}
651
- .alignMm=${f.alignMm}
652
- .gap=${f.gap}
653
- .partName=${this.partId}
654
- ?locked=${this.locked}
655
- ?said=${!!held}
656
- @fasten-change=${(e) => this.onFastenChange(asset, e.detail)}
657
- @fasten-release=${() => this.onFastenRelease(!!held)}
658
- ></figure-fasten-drawing>`
659
- : html `<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}
660
- ${(f.toPlane || f.target) ? html `<details fasten-detail>
661
- <summary>${i18next.t('figure.label.fasten-detail')}${detail.length ? html ` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}</summary>
662
- ${alignRows.length ? this.renderAlignment(alignRows, settle) : nothing}
663
- <div form>
664
- <span>${i18next.t('figure.label.gap')}</span>
665
- <input
666
- type="number"
667
- step="any"
668
- .value=${f.gapKept !== undefined ? '' : String(f.gap)}
669
- placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
670
- @change=${(e) => ((this.fasten = { ...f, gap: Number(e.target.value) || 0, gapKept: undefined }), settle())}
671
- />
672
- <span>${i18next.t('figure.label.twist')}</span>
673
- <input type="number" step="any" .value=${String(f.twist)} @change=${(e) => ((this.fasten = { ...f, twist: Number(e.target.value) || 0 }), settle())} />
674
- </div>
675
- </details>
676
- ${ /* What 붙이기 would do; a fastening the part has is changed at once, and the canvas shows it. */action && !held && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
677
- ${held
678
- ? nothing
679
- : html `<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
680
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
681
- </button>`}` : nothing}
511
+ <p hint>${i18next.t('figure.text.fasten-start')}</p>
512
+ <button act fasten-start ?disabled=${this.locked} @click=${() => this.openFastening()}>
513
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
514
+ </button>
682
515
  `;
683
516
  }
684
- /** 「lid 의 아랫면을 bin 의 윗면에 맞닿게」: what the fastening is, in one line, the part's own face said as a result. */
685
- sentenceOf(myFace, to, mode) {
686
- return i18next.t(`figure.text.fasten-sentence-${mode}`, { part: this.partId, face: i18next.t('figure.label.face-' + myFace), to });
687
- }
688
- /** A change the drawing asks: to the form only; the preview behind follows the form. */
689
- onFastenChange(asset, c) {
690
- const f = this.fasten;
691
- if ('targetFace' in c)
692
- this.aimAt(asset, { ...f, targetFace: c.targetFace });
693
- else if ('mode' in c)
694
- this.fasten = { ...f, face: c.mode === 'centre' ? 'centre' : f.face === 'centre' ? 'bottom' : f.face, facing: c.mode === 'flush' ? 'flush' : 'meet' };
695
- else if ('gap' in c)
696
- this.fasten = { ...f, gap: c.gap, gapKept: undefined };
697
- else
698
- this.fasten = {
699
- ...f,
700
- align: { ...f.align, [c.axis]: c.how },
701
- alignMm: c.how === 'mm' ? { ...f.alignMm, [c.axis]: c.mm ?? 0 } : f.alignMm
702
- };
703
- }
704
- /** Let go: a fastening the part already has is applied as it now is (one step); a new one waits for 붙이기. */
705
- async onFastenRelease(held) {
706
- if (!held)
707
- return;
708
- await this.updateComplete;
709
- if (this.fastenCandidate)
710
- this.command(this.fastenCandidate.action);
711
- }
712
517
  renderDetachWords() {
713
518
  /* One thing to say is a line, not a list of one. */
714
519
  if (this.detachWords.length === 1)
715
520
  return html `<p hint detach-words>${this.detachWords[0]}</p>`;
716
521
  return this.detachWords.length ? html `<ul detach-words>${this.detachWords.map(line => html `<li>${line}</li>`)}</ul>` : nothing;
717
522
  }
718
- /**
719
- * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
720
- * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
721
- */
722
- renderAlignment(axes, settle) {
723
- const f = this.fasten;
724
- const set = (axis, how) => ((this.fasten = { ...f, align: { ...f.align, [axis]: how } }), settle());
725
- const setMm = (axis, mm) => ((this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } }), settle());
726
- return html `<div form>
727
- ${axes.map(({ axis, edges }) => {
728
- const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis] : 'centre') : (f.align[axis] ?? 'centre');
729
- return html `<span>${i18next.t('figure.label.align-' + axis)}</span>
730
- <select @change=${(e) => set(axis, e.target.value)}>
731
- <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
732
- ${edges
733
- ? html `<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
734
- <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
735
- : nothing}
736
- ${f.alignInput?.[axis]
737
- ? html `<option value="input" ?selected=${how === 'input'}>${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis], key => i18next.t(key)) })}</option>`
738
- : nothing}
739
- <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
740
- ${this.asset && hasInstanceSize(this.asset, axis)
741
- ? html `<option value="share" ?selected=${how === 'share'}>${i18next.t('figure.label.align-share')}</option>`
742
- : nothing}
743
- </select>
744
- ${how === 'mm' || how === 'share'
745
- ? html `<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(f.alignMm[axis] ?? 0)} @change=${(e) => setMm(axis, Number(e.target.value) || 0)} />`
746
- : nothing}`;
747
- })}
748
- </div>`;
749
- }
750
523
  /**
751
524
  * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
752
525
  * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
@@ -778,7 +551,7 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
778
551
  ${head}
779
552
  <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
780
553
  ${blocked === 'unfastened'
781
- ? html `<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
554
+ ? html `<button act @click=${() => this.dispatchEvent(new CustomEvent('go-fasten', { bubbles: true, composed: true }))}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
782
555
  : nothing}
783
556
  </details></section>`;
784
557
  const axis = axes.includes(r.axis) ? r.axis : axes[0];
@@ -787,13 +560,13 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
787
560
  ? { kind: 'make-repeat', part: this.partId, axis, pitch: r.fills ? r.pitch : Math.max(r.pitch, 1), ...(r.fills ? {} : { count: r.count }) }
788
561
  : undefined;
789
562
  this.rowCandidate = action;
790
- const set = (next) => ((this.row = next), (this.working = 'row'));
563
+ const set = (next) => ((this.row = next), (this.rowOpen = true));
791
564
  /*
792
565
  Whether the row is being worked follows the fold's own toggle. A press on the heading focuses it before the click;
793
566
  set on focus, the fold opened there and the click then folded it again (2026-10-11: it took two clicks to open).
794
567
  */
795
- const toggled = (e) => (this.working = e.target.open ? 'row' : 'fasten');
796
- return html `<section row><details row-fold ?open=${this.working === 'row'} @toggle=${toggled}>
568
+ const toggled = (e) => (this.rowOpen = e.target.open);
569
+ return html `<section row><details row-fold ?open=${this.rowOpen} @toggle=${toggled}>
797
570
  ${head}
798
571
  <div form>
799
572
  <span>${i18next.t('figure.label.repeat-axis')}</span>
@@ -815,7 +588,7 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
815
588
  <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e) => set({ ...r, count: Number(e.target.value) })} />
816
589
  </div>
817
590
  <p hint>${i18next.t('figure.text.row-count-spreads')}</p>`}
818
- ${action && this.working === 'row' ? this.renderPreviewWords() : nothing}
591
+ ${action && this.rowOpen ? this.renderPreviewWords() : nothing}
819
592
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
820
593
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
821
594
  </button>
@@ -1057,26 +830,10 @@ __decorate([
1057
830
  property({ attribute: false }),
1058
831
  __metadata("design:type", Array)
1059
832
  ], FigureLinks.prototype, "detachWords", void 0);
1060
- __decorate([
1061
- state(),
1062
- __metadata("design:type", String)
1063
- ], FigureLinks.prototype, "working", void 0);
1064
- __decorate([
1065
- state(),
1066
- __metadata("design:type", Object)
1067
- ], FigureLinks.prototype, "choosingFastening", void 0);
1068
- __decorate([
1069
- state(),
1070
- __metadata("design:type", Object)
1071
- ], FigureLinks.prototype, "changingFastening", void 0);
1072
- __decorate([
1073
- state(),
1074
- __metadata("design:type", Object)
1075
- ], FigureLinks.prototype, "spotlit", void 0);
1076
833
  __decorate([
1077
834
  state(),
1078
835
  __metadata("design:type", Object)
1079
- ], FigureLinks.prototype, "fasten", void 0);
836
+ ], FigureLinks.prototype, "rowOpen", void 0);
1080
837
  __decorate([
1081
838
  state(),
1082
839
  __metadata("design:type", Object)