@things-factory/figure-ui 10.1.181 → 10.1.183

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 (54) 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-fasten-dialog.ts +442 -0
  6. package/client/modeller/figure-fasten-drawing.ts +32 -11
  7. package/client/modeller/figure-links.ts +56 -300
  8. package/client/modeller/modeller-keys.ts +4 -4
  9. package/dist-client/modeller/editor-styles.js +28 -9
  10. package/dist-client/modeller/editor-styles.js.map +1 -1
  11. package/dist-client/modeller/editor.d.ts +23 -2
  12. package/dist-client/modeller/editor.js +121 -12
  13. package/dist-client/modeller/editor.js.map +1 -1
  14. package/dist-client/modeller/fasten-form.d.ts +69 -0
  15. package/dist-client/modeller/fasten-form.js +131 -0
  16. package/dist-client/modeller/fasten-form.js.map +1 -0
  17. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  18. package/dist-client/modeller/fasten-geometry.js +6 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-fasten-dialog.d.ts +64 -0
  21. package/dist-client/modeller/figure-fasten-dialog.js +418 -0
  22. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  23. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  24. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  25. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  26. package/dist-client/modeller/figure-links.d.ts +15 -39
  27. package/dist-client/modeller/figure-links.js +58 -301
  28. package/dist-client/modeller/figure-links.js.map +1 -1
  29. package/dist-client/modeller/modeller-keys.js +4 -4
  30. package/dist-client/modeller/modeller-keys.js.map +1 -1
  31. package/dist-client/route.d.ts +1 -1
  32. package/dist-client/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +5 -5
  34. package/test/browser/links-page.js +4 -4
  35. package/test/browser/links.check.mjs +184 -46
  36. package/test/browser/modeller-page.js +23 -0
  37. package/test/browser/modeller.check.mjs +19 -20
  38. package/test/browser/namespace-page.js +2 -29
  39. package/test/browser/popup-stand-in.js +48 -0
  40. package/test/browser/product-page.check.mjs +4 -3
  41. package/test/browser/product-page.js +19 -32
  42. package/test/browser/props.check.mjs +28 -7
  43. package/test/browser/screen.check.mjs +28 -23
  44. package/test/browser/section-page.js +13 -41
  45. package/test/browser/section.check.mjs +39 -30
  46. package/test/fasten-form.test.ts +77 -0
  47. package/test/fasten-geometry.test.ts +2 -0
  48. package/test/first-minute.test.ts +7 -4
  49. package/translations/en.json +4 -3
  50. package/translations/es.json +4 -3
  51. package/translations/ja.json +4 -3
  52. package/translations/ko.json +4 -3
  53. package/translations/ms.json +4 -3
  54. package/translations/zh.json +4 -3
@@ -6,30 +6,24 @@ import './figure-slot-layout-panel.js'
6
6
  import { customElement, property, state } from 'lit/decorators.js'
7
7
 
8
8
  import { i18next, localize } from '@operato/i18n'
9
- import { FIGURE_FACES, figureFaceOverhangOf } from '@hatiolab/figure-model'
9
+ import { figureFaceOverhangOf } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
11
11
 
12
12
  import {
13
13
  acceptsOf,
14
- alignOf,
15
14
  AXES,
16
15
  canFasten,
17
- facesOf,
18
16
  canFollow,
19
17
  followCommandOf,
20
18
  followableAxes,
21
19
  positionIsShare,
22
20
  choiceOf,
23
21
  followWordsOf,
24
- fastenedTo,
25
22
  heldBy,
26
23
  followOf,
27
- hangsFrom,
28
24
  shownWhenOf,
29
25
  visibilityControlsOf,
30
- placeKeptOf,
31
26
  fasteningOf,
32
- fastenGapOf,
33
27
  hasInstanceSize,
34
28
  holdsThings,
35
29
  leadersFor,
@@ -46,42 +40,20 @@ import {
46
40
  fixedJointOf,
47
41
  rowOf,
48
42
  positionsWorkedOut,
49
- fieldWordsOf,
50
43
  measureNow,
51
- ownFacesOf,
52
- alignRowsOf,
53
- type Alignment,
54
44
  type Axis,
55
45
  type Follow,
56
46
  type MotionForm
57
47
  } from './links.js'
58
48
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
59
- import './figure-fasten-drawing.js'
60
- import type { FastenChange } from './figure-fasten-drawing.js'
61
- import { faceViewOf, insideAlignmentOf, modeOf, myFaceFor, turnsInPlace, type FaceView, type FastenMode } from './fasten-geometry.js'
49
+ import { insideAlignmentOf } from './fasten-geometry.js'
50
+ import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js'
62
51
  import type { FigureRevision } from './revision.js'
63
52
  import { HelpToggles } from './help.js'
64
53
  import { groupMenu, GroupMenuStyles } from './group-menu.js'
65
54
  import { jointAxisWord } from './joint-axis.js'
66
55
  import { placeTerms } from '@things-factory/figure-viewer/dist-client/detail-facts.js'
67
56
 
68
- const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
69
- /** The target list's entry for the plane the figure stands by. */
70
- const PLANE = '__plane'
71
-
72
- const freshFasten = () => ({
73
- face: 'bottom' as FigureFace | 'centre',
74
- toPlane: false,
75
- target: '',
76
- targetFace: 'top' as FigureFace,
77
- facing: 'meet' as 'meet' | 'flush',
78
- gap: 0,
79
- gapKept: undefined as unknown,
80
- twist: 0,
81
- align: {} as Partial<Record<Axis, Alignment>>,
82
- alignMm: {} as Partial<Record<Axis, number>>,
83
- alignInput: {} as Partial<Record<Axis, string>>
84
- })
85
57
  const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 })
86
58
  const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
87
59
  const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as FigureFace, label: '', accepts: '', capacity: 1 })
@@ -96,7 +68,8 @@ const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as Figu
96
68
  *
97
69
  * The sections are dimensions, placement and joint, in that order. Every
98
70
  * change is one figure-model authoring command, sent as `command` and run by the editor through the session — one
99
- * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
71
+ * step of the undo history. The fastening is said here in one line; it is chosen in the fastening window
72
+ * (`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
100
73
  * size the figure does not declare) are not offered, so the author is not sent into a refusal.
101
74
  */
102
75
  @customElement('figure-links')
@@ -148,7 +121,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
148
121
  details[resize-choice] { margin: 0; padding: 0; border: 0; }
149
122
  details[resize-choice] > div { display: grid; gap: 8px; padding: 8px 0; }
150
123
  p[fasten-mode] { margin: 0; font-weight: 600; }
151
- section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
152
124
  details { margin-top: 8px; }
153
125
  details[row-fold] { margin: 0; }
154
126
  details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
@@ -162,7 +134,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
162
134
  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); }
163
135
  div[overhang] p { margin: 0; }
164
136
  ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; }
165
- span[fasten-summary] { opacity: 0.8; }
166
137
  /* The difference a following measure keeps: under its choice, on the same line as its words. */
167
138
  label[inline-plus] {
168
139
  display: flex;
@@ -186,11 +157,12 @@ export class FigureLinks extends localize(i18next)(LitElement) {
186
157
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
187
158
  @property() section: 'shape' | 'response' | 'places' | 'motion' = 'shape'
188
159
  /**
189
- * Its tab is the one shown. Two sections speak of what a command would draw -- the properties' fastening and the
190
- * response's row -- and only the one in sight may: the hidden one's silence would clear what the shown one draws.
160
+ * Its tab is the one shown, and no window over it speaks instead. The response's row speaks of what a command would
161
+ * draw, and the properties' says there is none: only the one in sight may, as the hidden one's silence would clear what
162
+ * the shown one draws. While the fastening window is open it alone speaks (the editor sets this false).
191
163
  */
192
164
  @property({ type: Boolean }) active = true
193
- /** What the fastening command would do, in words (the editor works it out: `command-preview.ts`). */
165
+ /** What the row's command would do, in words (the editor works it out: `command-preview.ts`). */
194
166
  @property({ attribute: false }) previewWords: string[] = []
195
167
  /** Where the selected part's motion takes it, at both ends of its range (the editor draws them on the canvas). */
196
168
  @property({ attribute: false }) motionEnds: { control: string; min: number; max: number; unit: string } | null = null
@@ -201,23 +173,15 @@ export class FigureLinks extends localize(i18next)(LitElement) {
201
173
  /** What taking this part off would do, said beside the buttons that do it (worked out on a copy by the editor). */
202
174
  @property({ attribute: false }) detachWords: string[] = []
203
175
  /*
204
- The fastening command the form describes now, and the face it aims at: told to the editor whenever it changes, so
205
- the result is seen before it is done (ADR-0097 §5.5). Nothing is told while no target is chosen.
176
+ What a section speaks of on the canvas: the response's row while its fold is open; the properties', the fastening the
177
+ part has -- its face lit, as the one-line result says it.
206
178
  */
207
179
  private candidate?: { action: FigureAuthoringAction; face?: { part: string; face: string } }
208
- private told = ''
209
- /* Two forms describe a command here: the fastening and the row. The one being worked on is the one seen. */
210
- @state() private working: 'fasten' | 'row' = 'fasten'
211
180
  private fastenCandidate?: { action: FigureAuthoringAction; face?: { part: string; face: string } }
181
+ private told = ''
182
+ @state() private rowOpen = false
212
183
  private rowCandidate?: FigureAuthoringAction
213
184
 
214
- /** A new fastening starts with a target. Its face and alignment are asked only after that target is chosen. */
215
- @state() private choosingFastening = false
216
- /** The fastening the part has, opened to be changed: until then the place group says it in one line. */
217
- @state() private changingFastening = false
218
- /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
219
- @state() private spotlit = false
220
- @state() private fasten = freshFasten()
221
185
  /** Why 정렬·간격 고치기 moved nothing, said under it; empty once it moved the part or the part changed. */
222
186
  @state() private fixNote = ''
223
187
  @state() private motion: MotionForm = freshMotion()
@@ -242,15 +206,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
242
206
  if (changed.has('asset') || changed.has('partId')) this.fixNote = ''
243
207
  if (this.asset && this.partId && this.partId !== this.loadedFor) {
244
208
  this.loadedFor = this.partId
245
- /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
246
- const own = this.revision && this.partId ? fasteningOf(this.revision, this.partId) : null
247
- this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
248
209
  this.motion = freshMotion()
249
210
  this.place = freshPlace()
250
211
  this.row = freshRow()
251
212
  this.editingMotion = false
252
- this.choosingFastening = false
253
- this.changingFastening = false
254
213
  }
255
214
  }
256
215
 
@@ -329,7 +288,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
329
288
  : nothing}
330
289
  ${rowOf(asset, this.partId) ? html`<slot name="row"></slot>` : this.renderRow(asset)}
331
290
  `
332
- this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : undefined
291
+ this.candidate = this.rowOpen && this.rowCandidate ? { action: this.rowCandidate } : undefined
333
292
  return response
334
293
  }
335
294
  const shown = html`
@@ -337,7 +296,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
337
296
  <h3>${i18next.t('figure.label.group-size')}</h3>
338
297
  <slot name="size"></slot>
339
298
  </section>
340
- <section fastening ?spotlit=${this.spotlit} @focusin=${() => (this.working = 'fasten')}>
299
+ <section fastening>
341
300
  <h3>
342
301
  ${i18next.t('figure.label.group-place')}${this.help.button('figure.text.fastening-explained')}
343
302
  ${groupMenu([{ label: i18next.t('figure.button.detach'), icon: 'link_off', disabled: this.locked || !held, run: () => this.command({ kind: 'detach', part: this.partId }) }])}
@@ -494,53 +453,36 @@ export class FigureLinks extends localize(i18next)(LitElement) {
494
453
  })}</div></details>`
495
454
  }
496
455
 
497
- /**
498
- * "Open attachment settings": the fastening this part has now -- its face, what it is on, the gap -- in the form below,
499
- * brought into view with the focus on it. The form is loaded again first: the part was fastened by "follow size,
500
- * attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
501
- * holding places instead).
502
- */
503
- /** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
504
456
  /**
505
457
  * 정렬·간격 고치기: the part moved inside the face it is fastened on, as one step -- the least move on each side it is
506
458
  * out, or centred when it is wider than the face. When nothing of it can be moved, why is said under the button, not
507
- * nothing at all (:3300, 2026-10-11: the button only scrolled to the drawing, which was already in view).
459
+ * nothing at all (:3300, 2026-10-11: the button only scrolled to the drawing, which was already in view). The command
460
+ * is the fastening window's for the same choice (`fasten-form.ts`), with only the place across the face changed.
508
461
  */
509
- private async fixAlignment(view: FaceView | undefined, target: string) {
462
+ private fixAlignment() {
463
+ const revision = this.revision
464
+ if (!revision) return
465
+ const form = fastenFormOf(revision, this.partId)
510
466
  /* 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. */
511
- const own = this.revision ? figureFaceOverhangOf(this.revision.asset, this.partId, this.revision.inputs) : undefined
467
+ const own = figureFaceOverhangOf(revision.asset, this.partId, revision.inputs)
512
468
  const fixed =
513
469
  own?.state === 'unknown'
514
470
  ? ({ cannot: 'unmeasured' } as const)
515
471
  : own?.state === 'out' || own?.state === 'possible'
516
- ? insideAlignmentOf(view, this.fasten.align, this.fasten.alignMm)
472
+ ? insideAlignmentOf(fastenPlanOf(revision, this.partId, form).view, form.align, form.alignMm)
517
473
  : ({ cannot: 'inside' } as const)
518
474
  if ('cannot' in fixed) {
519
- this.fixNote = i18next.t(`figure.text.fix-alignment-${fixed.cannot}`, { part: this.partId, on: target })
475
+ this.fixNote = i18next.t(`figure.text.fix-alignment-${fixed.cannot}`, { part: this.partId, on: form.target })
520
476
  return
521
477
  }
522
478
  this.fixNote = ''
523
- this.fasten = { ...this.fasten, align: fixed.align, alignMm: fixed.alignMm }
524
- await this.onFastenRelease(true)
525
- }
526
-
527
- /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
528
- async showFastening() {
529
- if (this.asset) await this.openFastening(this.asset)
479
+ const action = fastenPlanOf(revision, this.partId, { ...form, align: fixed.align, alignMm: fixed.alignMm }).action
480
+ if (action) this.command(action)
530
481
  }
531
482
 
532
- private async openFastening(asset: FigureAsset) {
533
- const own = fasteningOf(this.revision!, this.partId)
534
- this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
535
- /* Asked for from elsewhere: the form itself, not the one-line summary of the fastening it has. */
536
- this.changingFastening = true
537
- this.spotlit = true
538
- await this.updateComplete
539
- const section = this.renderRoot.querySelector<HTMLElement>('section[fastening]')
540
- section?.scrollIntoView({ block: 'start', behavior: 'smooth' })
541
- /* Into the form itself, not the section's help button. */
542
- ;(section?.querySelector<HTMLElement>('select, input') ?? section?.querySelector<HTMLElement>('button'))?.focus({ preventScroll: true })
543
- setTimeout(() => (this.spotlit = false), 1600)
483
+ /** The fastening window, asked of the editor: it opens over the canvas on what the part has now. */
484
+ private openFastening() {
485
+ this.dispatchEvent(new CustomEvent('fasten-open', { bubbles: true, composed: true }))
544
486
  }
545
487
 
546
488
  /**
@@ -597,247 +539,61 @@ export class FigureLinks extends localize(i18next)(LitElement) {
597
539
  }
598
540
 
599
541
  /**
600
- * A new aim for the fastening: where on the face is filled in as where the part stands now, so fastening it keeps it
601
- * there (`placeKeptOf`). The author changes it from there.
542
+ * The fastening in one line -- what, on which face, how -- and 「바꾸기」 to open the window where it is chosen (chief
543
+ * architect 2026-10-16; user 2026-10-17: the result here, the editing in a window with room). Taking it off is in the
544
+ * group's 「⋯」. What hangs over the face, and the way to fix it, stay here: they are about the fastening as it is now.
602
545
  */
603
- private aimAt(asset: FigureAsset, f: ReturnType<typeof freshFasten>) {
604
- const theirFaces = f.target ? facesOf(asset, f.target) : FACES
605
- const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
606
- const aim = f.toPlane ? ('plane' as const) : f.target ? { part: f.target, face: targetFace } : undefined
607
- this.fasten = { ...f, ...(aim ? placeKeptOf(this.revision!, this.partId, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) }
608
- }
609
-
610
546
  private renderFastening(asset: FigureAsset) {
611
- const held = heldBy(asset, this.partId)
612
- /* 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. */
613
- if (fasteningOf(this.revision!, this.partId)?.atOrigin) {
614
- this.fastenCandidate = undefined
547
+ const revision = this.revision!
548
+ const own = fasteningOf(revision, this.partId)
549
+ /* 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. */
550
+ if (own?.atOrigin)
615
551
  return html`<div fastened fasten-at-origin>
616
552
  <p>${this.heldWords(asset)}</p>
617
553
  ${this.renderDetachWords()}
618
554
  </div>`
619
- }
620
- const f = this.fasten
621
- /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
622
- const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
623
- /* Shown, not dropped, and not choosable, with the reason (ADR-0097 §5.5): fastening it would close a loop. */
624
- const loops = (one: string) => hangsFrom(asset, one, this.partId)
625
- const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'))
626
- /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
627
- const mineFaces = ownFacesOf(asset, this.partId, f.toPlane)
628
- const theirFaces = f.target ? facesOf(asset, f.target) : FACES
629
- const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
630
- const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
631
- /* 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). */
632
- const mode: FastenMode = f.toPlane ? 'meet' : modeOf(f.face, f.facing)
633
- const derived = f.toPlane || f.target ? myFaceFor(this.revision!, this.partId, aim, mode) : undefined
634
- const myFace = derived ?? (mineFaces.includes(f.face) ? f.face : mineFaces[0]!)
635
- const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist, f.alignInput) : undefined
636
- const action =
637
- f.toPlane || f.target
638
- ? ({
639
- kind: 'attach',
640
- part: this.partId,
641
- face: myFace,
642
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
643
- ...(f.toPlane ? {} : { facing: f.facing }),
644
- ...fastenGapOf(f),
645
- twist: f.twist,
646
- ...(align ? { align } : {}),
647
- replace: !!held
648
- } as FigureAuthoringAction)
649
- : undefined
650
- if (action) this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) }
651
- /*
652
- What the folded details hold that is not their default, said while folded; the way and the place on the face are
653
- the drawing's, and said there (user 2026-10-02: the details had selects repeating the drawing). Nothing is said
654
- for the default.
655
- */
656
- const detail = [
657
- ...(f.gapKept !== undefined
658
- ? [i18next.t('figure.text.fasten-summary-gap-worked-out')]
659
- : f.gap
660
- ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })]
661
- : []),
662
- ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
663
- ]
664
- /* 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. */
665
- const view = f.toPlane || f.target ? faceViewOf(this.revision!, this.partId, aim) : undefined
666
- const alignRows = f.toPlane || f.target ? alignRowsOf(asset, this.partId, aim, view, !!f.twist, f.alignInput) : []
667
- /* A fastening the part has is changed as soon as something is chosen, as the drawing does; a new one waits for 붙이기. */
668
- const settle = () => this.onFastenRelease(!!held)
669
- /*
670
- 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
671
- said once, by the sentence over the drawing -- the panel's head says it too; a third and a fourth line saying the
672
- same (「현재 관계 수정」, 「붙은 곳」) went (user 2026-10-01, the panel checked for balance).
673
- */
674
- const current = asset.joints?.find(j => j.body1.part === this.partId)
675
- const editing = i18next.t('figure.text.fasten-new')
676
- /*
677
- A fastening the part has, said in one line -- what, on which face, how -- and 「바꾸기」 to open the drawing and the
678
- details (chief architect 2026-10-16). They were open on every fastened part, near a screen of the panel, for a
679
- choice made once. Taking it off is in the group's 「⋯」. What would hang over still shows: it is about now.
680
- */
681
- /* What hangs over the face, and the way to fix it: about the fastening as it is now, so said open or folded. */
682
555
  const overhang = html`${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
683
556
  ${this.overhangWords.length
684
557
  ? html`<div overhang role="status">
685
558
  ${this.overhangWords.map(line => html`<p>${line}</p>`)}
686
- ${current
687
- ? html`<button act fix-alignment ?disabled=${this.locked} @click=${() => this.fixAlignment(view, held ? f.target : '')}>
559
+ ${own
560
+ ? html`<button act fix-alignment ?disabled=${this.locked} @click=${() => this.fixAlignment()}>
688
561
  <md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}
689
562
  </button>
690
563
  ${this.fixNote ? html`<p hint fix-note>${this.fixNote}</p>` : nothing}`
691
564
  : nothing}
692
565
  </div>`
693
566
  : nothing}`
694
- if (current && held && !this.changingFastening && (f.toPlane || f.target))
567
+ if (own && heldBy(asset, this.partId)) {
568
+ const form = fastenFormOf(revision, this.partId)
569
+ const plan = fastenPlanOf(revision, this.partId, form)
570
+ if (plan.action) this.fastenCandidate = { action: plan.action, ...(plan.face ? { face: plan.face } : {}) }
571
+ const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'))
695
572
  return html`
696
573
  ${overhang}
697
574
  <div fastened-summary>
698
- <p fasten-sentence>${this.sentenceOf(myFace, f.toPlane ? planeWord : `${f.target}의 ${i18next.t('figure.label.face-' + targetFace)}`, mode)}</p>
699
- <button act change-fastening ?disabled=${this.locked} @click=${() => (this.changingFastening = true)}>${i18next.t('figure.button.change-fastening')}</button>
575
+ <p fasten-sentence>${fastenSentenceOf(this.partId, form, plan, planeWord, (key, params) => i18next.t(key, params))}</p>
576
+ <button act change-fastening ?disabled=${this.locked} @click=${() => this.openFastening()}>${i18next.t('figure.button.change-fastening')}</button>
700
577
  </div>
701
578
  ${this.renderDetachWords()}
702
579
  `
703
- /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
704
- if (!current && !this.choosingFastening && !f.target)
705
- return html`
706
- <p hint>${i18next.t('figure.text.fasten-start')}</p>
707
- <button act ?disabled=${this.locked} @click=${() => (this.choosingFastening = true)}>
708
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
709
- </button>
710
- `
580
+ }
581
+ /* A blank attachment is not a form to decipher: what it does, and the way to the window. */
711
582
  return html`
712
- ${current ? nothing : html`<p fasten-mode>${editing}</p>`}
713
583
  ${overhang}
714
- ${held ? this.renderDetachWords() : nothing}
715
- <div form>
716
- <span>${i18next.t('figure.label.target-part')}</span>
717
- <select @change=${(e: Event) => {
718
- const value = (e.target as HTMLSelectElement).value
719
- this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value })
720
- if (value) settle()
721
- }}>
722
- <option value="" ?selected=${!f.target && !f.toPlane}>${i18next.t('figure.label.target-part-placeholder')}</option>
723
- <option value=${PLANE} ?selected=${f.toPlane}>${planeWord}</option>
724
- ${targets.map(one =>
725
- loops(one)
726
- ? html`<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
727
- : html`<option value=${one} ?selected=${one === f.target && !f.toPlane}>${one}</option>`
728
- )}
729
- </select>
730
- </div>
731
- ${f.toPlane || f.target
732
- ? html`<p fasten-sentence>${this.sentenceOf(myFace, f.toPlane ? planeWord : `${f.target}의 ${i18next.t('figure.label.face-' + targetFace)}`, mode)}</p>
733
- <figure-fasten-drawing
734
- .target=${f.toPlane ? '' : f.target}
735
- .planeWord=${planeWord}
736
- .faces=${theirFaces}
737
- .face=${targetFace}
738
- .modes=${f.toPlane ? (['meet'] as FastenMode[]) : ([...(['meet', 'flush'] as FastenMode[]), ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])])}
739
- .mode=${mode}
740
- .view=${view}
741
- .align=${f.align}
742
- .alignMm=${f.alignMm}
743
- .gap=${f.gap}
744
- .partName=${this.partId}
745
- ?locked=${this.locked}
746
- ?said=${!!held}
747
- @fasten-change=${(e: CustomEvent<FastenChange>) => this.onFastenChange(asset, e.detail)}
748
- @fasten-release=${() => this.onFastenRelease(!!held)}
749
- ></figure-fasten-drawing>`
750
- : html`<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}
751
- ${(f.toPlane || f.target) ? html`<details fasten-detail>
752
- <summary>${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}</summary>
753
- ${alignRows.length ? this.renderAlignment(alignRows, settle) : nothing}
754
- <div form>
755
- <span>${i18next.t('figure.label.gap')}</span>
756
- <input
757
- type="number"
758
- step="any"
759
- .value=${f.gapKept !== undefined ? '' : String(f.gap)}
760
- placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
761
- @change=${(e: Event) => ((this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined }), settle())}
762
- />
763
- <span>${i18next.t('figure.label.twist')}</span>
764
- <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => ((this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
765
- </div>
766
- </details>
767
- ${/* 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}
768
- ${held
769
- ? nothing
770
- : html`<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
771
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
772
- </button>`}` : nothing}
584
+ <p hint>${i18next.t('figure.text.fasten-start')}</p>
585
+ <button act fasten-start ?disabled=${this.locked} @click=${() => this.openFastening()}>
586
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
587
+ </button>
773
588
  `
774
589
  }
775
590
 
776
- /** 「lid 의 아랫면을 bin 의 윗면에 맞닿게」: what the fastening is, in one line, the part's own face said as a result. */
777
- private sentenceOf(myFace: string, to: string, mode: FastenMode) {
778
- return i18next.t(`figure.text.fasten-sentence-${mode}`, { part: this.partId, face: i18next.t('figure.label.face-' + myFace), to })
779
- }
780
-
781
- /** A change the drawing asks: to the form only; the preview behind follows the form. */
782
- private onFastenChange(asset: FigureAsset, c: FastenChange) {
783
- const f = this.fasten
784
- if ('targetFace' in c) this.aimAt(asset, { ...f, targetFace: c.targetFace as FigureFace })
785
- else if ('mode' in c) this.fasten = { ...f, face: c.mode === 'centre' ? 'centre' : f.face === 'centre' ? 'bottom' : f.face, facing: c.mode === 'flush' ? 'flush' : 'meet' }
786
- else if ('gap' in c) this.fasten = { ...f, gap: c.gap, gapKept: undefined }
787
- else
788
- this.fasten = {
789
- ...f,
790
- align: { ...f.align, [c.axis]: c.how },
791
- alignMm: c.how === 'mm' ? { ...f.alignMm, [c.axis]: c.mm ?? 0 } : f.alignMm
792
- }
793
- }
794
-
795
- /** Let go: a fastening the part already has is applied as it now is (one step); a new one waits for 붙이기. */
796
- private async onFastenRelease(held: boolean) {
797
- if (!held) return
798
- await this.updateComplete
799
- if (this.fastenCandidate) this.command(this.fastenCandidate.action)
800
- }
801
-
802
591
  private renderDetachWords() {
803
592
  /* One thing to say is a line, not a list of one. */
804
593
  if (this.detachWords.length === 1) return html`<p hint detach-words>${this.detachWords[0]}</p>`
805
594
  return this.detachWords.length ? html`<ul detach-words>${this.detachWords.map(line => html`<li>${line}</li>`)}</ul>` : nothing
806
595
  }
807
596
 
808
- /**
809
- * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
810
- * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
811
- */
812
- private renderAlignment(axes: { axis: Axis; edges: boolean }[], settle: () => void) {
813
- const f = this.fasten
814
- const set = (axis: Axis, how: Alignment) => ((this.fasten = { ...f, align: { ...f.align, [axis]: how } }), settle())
815
- const setMm = (axis: Axis, mm: number) => ((this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } }), settle())
816
- return html`<div form>
817
- ${axes.map(({ axis, edges }) => {
818
- const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
819
- return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
820
- <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
821
- <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
822
- ${edges
823
- ? html`<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
824
- <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
825
- : nothing}
826
- ${f.alignInput?.[axis]
827
- ? html`<option value="input" ?selected=${how === 'input'}>${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) })}</option>`
828
- : nothing}
829
- <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
830
- ${this.asset && hasInstanceSize(this.asset, axis)
831
- ? html`<option value="share" ?selected=${how === 'share'}>${i18next.t('figure.label.align-share')}</option>`
832
- : nothing}
833
- </select>
834
- ${how === 'mm' || how === 'share'
835
- ? html`<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(f.alignMm[axis] ?? 0)} @change=${(e: Event) => setMm(axis, Number((e.target as HTMLInputElement).value) || 0)} />`
836
- : nothing}`
837
- })}
838
- </div>`
839
- }
840
-
841
597
  /**
842
598
  * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
843
599
  * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
@@ -868,7 +624,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
868
624
  ${head}
869
625
  <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
870
626
  ${blocked === 'unfastened'
871
- ? html`<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
627
+ ? 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>`
872
628
  : nothing}
873
629
  </details></section>`
874
630
  const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
@@ -877,13 +633,13 @@ export class FigureLinks extends localize(i18next)(LitElement) {
877
633
  ? ({ kind: 'make-repeat', part: this.partId, axis, pitch: r.fills ? r.pitch : Math.max(r.pitch, 1), ...(r.fills ? {} : { count: r.count }) } as FigureAuthoringAction)
878
634
  : undefined
879
635
  this.rowCandidate = action
880
- const set = (next: typeof r) => ((this.row = next), (this.working = 'row'))
636
+ const set = (next: typeof r) => ((this.row = next), (this.rowOpen = true))
881
637
  /*
882
638
  Whether the row is being worked follows the fold's own toggle. A press on the heading focuses it before the click;
883
639
  set on focus, the fold opened there and the click then folded it again (2026-10-11: it took two clicks to open).
884
640
  */
885
- const toggled = (e: Event) => (this.working = (e.target as HTMLDetailsElement).open ? 'row' : 'fasten')
886
- return html`<section row><details row-fold ?open=${this.working === 'row'} @toggle=${toggled}>
641
+ const toggled = (e: Event) => (this.rowOpen = (e.target as HTMLDetailsElement).open)
642
+ return html`<section row><details row-fold ?open=${this.rowOpen} @toggle=${toggled}>
887
643
  ${head}
888
644
  <div form>
889
645
  <span>${i18next.t('figure.label.repeat-axis')}</span>
@@ -905,7 +661,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
905
661
  <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => set({ ...r, count: Number((e.target as HTMLInputElement).value) })} />
906
662
  </div>
907
663
  <p hint>${i18next.t('figure.text.row-count-spreads')}</p>`}
908
- ${action && this.working === 'row' ? this.renderPreviewWords() : nothing}
664
+ ${action && this.rowOpen ? this.renderPreviewWords() : nothing}
909
665
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
910
666
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
911
667
  </button>
@@ -36,11 +36,11 @@ export class ModellerKeys implements ReactiveController {
36
36
  const canvas = this.of.scene()
37
37
  const path = e.composedPath()
38
38
  /*
39
- A key for something open over the work -- a group's 「⋯」 menu, the view or the history, a question -- is that
40
- thing's: heard here first, Esc deselected the part and was kept from the menu, which then could not be closed by
41
- the keyboard (2026-10-16).
39
+ A key for something open over the work -- a group's 「⋯」 menu, the view or the history, a question, a popup -- is
40
+ that thing's: heard here first, Esc deselected the part and was kept from the menu, which then could not be closed
41
+ by the keyboard (2026-10-16).
42
42
  */
43
- if (path.some(node => node instanceof Element && (node.localName.startsWith('ox-popup') || node.getAttribute('role') === 'dialog'))) return
43
+ if (path.some(node => node instanceof Element && (node.localName.startsWith('ox-popup') || node.localName === 'ox-floating-overlay' || node.getAttribute('role') === 'dialog'))) return
44
44
  const action = keyActionOf(
45
45
  {
46
46
  key: e.key,
@@ -90,11 +90,27 @@ export const EditorStyles = css `
90
90
  color: var(--md-sys-color-primary);
91
91
  font-weight: 600;
92
92
  }
93
- ox-canvas-bar button[save] {
93
+ /*
94
+ The bar's two commitments, one shape (user 2026-10-18: 「발행버튼도 저장버튼도 라운드 버튼으로 … 버튼 안에 적절한 아이콘」):
95
+ round, an icon inside, so neither is taken for the other. Save, done many times a day, is the filled one; release,
96
+ a promise to whoever draws the figure, is outlined.
97
+ */
98
+ ox-canvas-bar button[save],
99
+ ox-canvas-bar button[publish] {
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: 6px;
94
103
  height: 32px;
95
- padding: 0 14px;
96
- border: none;
104
+ padding: 0 14px 0 10px;
97
105
  border-radius: 999em;
106
+ font-weight: 600;
107
+ }
108
+ ox-canvas-bar button[save] md-icon,
109
+ ox-canvas-bar button[publish] md-icon {
110
+ --md-icon-size: 18px;
111
+ }
112
+ ox-canvas-bar button[save] {
113
+ border: 1px solid transparent;
98
114
  color: var(--md-sys-color-on-primary);
99
115
  background: var(--md-sys-color-primary);
100
116
  }
@@ -359,9 +375,10 @@ export const EditorStyles = css `
359
375
  align-items: center;
360
376
  gap: 4px;
361
377
  height: 32px;
362
- padding: 0 10px;
378
+ padding: 0 14px 0 10px;
363
379
  border: none;
364
- border-radius: 6px;
380
+ /* Round as save and release beside it: one shape for the bar's end. */
381
+ border-radius: 999em;
365
382
  color: var(--md-sys-color-on-error-container);
366
383
  background: var(--md-sys-color-error-container);
367
384
  }
@@ -382,12 +399,14 @@ export const EditorStyles = css `
382
399
  box-shadow: var(--md-sys-elevation-2, 0 2px 8px #0003);
383
400
  }
384
401
  ox-canvas-bar button[publish] {
385
- height: 32px;
386
- padding: 0 8px;
387
- border: 1px solid var(--md-sys-color-outline-variant);
388
- border-radius: 6px;
402
+ border: 1px solid var(--md-sys-color-outline);
403
+ color: var(--md-sys-color-primary);
389
404
  background: var(--md-sys-color-surface);
390
405
  }
406
+ ox-canvas-bar button[publish][aria-pressed='true'] {
407
+ background: var(--md-sys-color-secondary-container);
408
+ color: var(--md-sys-color-on-secondary-container);
409
+ }
391
410
  button:disabled {
392
411
  opacity: 0.45;
393
412
  cursor: default;