@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
@@ -91,11 +91,27 @@ export const EditorStyles = css`
91
91
  color: var(--md-sys-color-primary);
92
92
  font-weight: 600;
93
93
  }
94
- ox-canvas-bar button[save] {
94
+ /*
95
+ The bar's two commitments, one shape (user 2026-10-18: 「발행버튼도 저장버튼도 라운드 버튼으로 … 버튼 안에 적절한 아이콘」):
96
+ round, an icon inside, so neither is taken for the other. Save, done many times a day, is the filled one; release,
97
+ a promise to whoever draws the figure, is outlined.
98
+ */
99
+ ox-canvas-bar button[save],
100
+ ox-canvas-bar button[publish] {
101
+ display: inline-flex;
102
+ align-items: center;
103
+ gap: 6px;
95
104
  height: 32px;
96
- padding: 0 14px;
97
- border: none;
105
+ padding: 0 14px 0 10px;
98
106
  border-radius: 999em;
107
+ font-weight: 600;
108
+ }
109
+ ox-canvas-bar button[save] md-icon,
110
+ ox-canvas-bar button[publish] md-icon {
111
+ --md-icon-size: 18px;
112
+ }
113
+ ox-canvas-bar button[save] {
114
+ border: 1px solid transparent;
99
115
  color: var(--md-sys-color-on-primary);
100
116
  background: var(--md-sys-color-primary);
101
117
  }
@@ -360,9 +376,10 @@ export const EditorStyles = css`
360
376
  align-items: center;
361
377
  gap: 4px;
362
378
  height: 32px;
363
- padding: 0 10px;
379
+ padding: 0 14px 0 10px;
364
380
  border: none;
365
- border-radius: 6px;
381
+ /* Round as save and release beside it: one shape for the bar's end. */
382
+ border-radius: 999em;
366
383
  color: var(--md-sys-color-on-error-container);
367
384
  background: var(--md-sys-color-error-container);
368
385
  }
@@ -383,12 +400,14 @@ export const EditorStyles = css`
383
400
  box-shadow: var(--md-sys-elevation-2, 0 2px 8px #0003);
384
401
  }
385
402
  ox-canvas-bar button[publish] {
386
- height: 32px;
387
- padding: 0 8px;
388
- border: 1px solid var(--md-sys-color-outline-variant);
389
- border-radius: 6px;
403
+ border: 1px solid var(--md-sys-color-outline);
404
+ color: var(--md-sys-color-primary);
390
405
  background: var(--md-sys-color-surface);
391
406
  }
407
+ ox-canvas-bar button[publish][aria-pressed='true'] {
408
+ background: var(--md-sys-color-secondary-container);
409
+ color: var(--md-sys-color-on-secondary-container);
410
+ }
392
411
  button:disabled {
393
412
  opacity: 0.45;
394
413
  cursor: default;
@@ -6,7 +6,7 @@ import { openPopup } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
12
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
@@ -18,10 +18,11 @@ import './figure-parts.js'
18
18
  import './figure-material-panel.js'
19
19
  import './figure-section-panel.js'
20
20
  import './figure-section-editor.js'
21
- import { rowWordsOf } from './command-preview.js'
21
+ import { faceHighlightOf, rowWordsOf } from './command-preview.js'
22
22
  import { CommandPreview, NO_WORDS } from './command-preview-controller.js'
23
23
  import { motionEndsOf, type MotionEnds } from './motion-ends.js'
24
24
  import './figure-links.js'
25
+ import './figure-fasten-dialog.js'
25
26
  import './figure-controls.js'
26
27
  import './figure-clips.js'
27
28
  import './figure-data-panel.js'
@@ -137,6 +138,17 @@ export class FigureEditor extends LitElement {
137
138
  @state() private tab: SideTab = 'properties'
138
139
  /** The view settings or the release history, open over the work from the bar. */
139
140
  @state() private overlay?: BarOverlay
141
+ /**
142
+ * The fastening window open for the selected part (user 2026-10-17), in the standard popup (2026-10-18): chosen there,
143
+ * applied once. It closes with nothing written when the part, the tab or the middle changes under it.
144
+ */
145
+ @state() private fastening = false
146
+ private fastenPopup?: { close: () => void }
147
+ private fastenWindow?: HTMLElement & { revision?: FigureRevision; partId: string; locked: boolean; previewWords: string[]; error: string }
148
+ /** Why the window's 「적용」 was refused, said in it. */
149
+ @state() private fastenError = ''
150
+ /** The face of the part a row of the window's list is on, lit on the canvas while the pointer or the arrows are there. */
151
+ @state() private fastenHover: FigureRenderPart | null = null
140
152
  /** 「더 보기」 open, as this person left it last (chief architect 2026-10-16: kept per person). */
141
153
  @state() private moreOpen = FigureEditor.storedMoreOpen()
142
154
  private static storedMoreOpen(): boolean {
@@ -279,6 +291,8 @@ export class FigureEditor extends LitElement {
279
291
  }
280
292
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
281
293
  if (_changed.has('selected')) this.gizmoMode = undefined
294
+ /* The window is about the part chosen on the properties tab, being edited: it does not follow anywhere else. */
295
+ if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit')) this.closeFastening()
282
296
  /*
283
297
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
284
298
  * own Esc, the part removed, another figure opened -- it closes and lets go of the preview it held.
@@ -765,10 +779,11 @@ export class FigureEditor extends LitElement {
765
779
  return html`<figure-links
766
780
  .revision=${shown}
767
781
  .section=${section}
768
- .active=${this.tab === tab}
782
+ .active=${this.tab === tab && !(section === 'shape' && this.fastening)}
769
783
  .partId=${this.selected}
770
784
  ?locked=${unavailable}
771
785
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
786
+ @fasten-open=${() => this.openFastening()}
772
787
  .previewWords=${this.commandPreview.words()}
773
788
  .detachWords=${this.commandPreview.detachWords(shown)}
774
789
  .overhangWords=${section === 'shape' ? this.overhangOf(shown).words : NO_WORDS}
@@ -881,7 +896,7 @@ export class FigureEditor extends LitElement {
881
896
  <div overlay-body>${body}</div>
882
897
  </div>`
883
898
  return html`${panel('view', 'figure.label.tab-environment', html`<figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>`)}
884
- ${panel('history', 'figure.label.history', html`<slot name="history"></slot>`)}`
899
+ ${panel('history', 'figure.button.release', html`<slot name="history"></slot>`)}`
885
900
  }
886
901
  /**
887
902
  * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
@@ -1366,8 +1381,16 @@ export class FigureEditor extends LitElement {
1366
1381
  </ul>
1367
1382
  </div>`
1368
1383
  }
1369
- /** To the release that exists: the history's release section, opened from the bar, not a second one here. */
1384
+ /*
1385
+ The bar's one way to the releases (user 2026-10-18: the history icon and 「발행」 opened the same panel): the panel
1386
+ of releases -- what to release now, the releases before it and going back to one -- opened at its release section,
1387
+ closed again by a second press.
1388
+ */
1370
1389
  private goRelease = async () => {
1390
+ if (this.overlay === 'history') {
1391
+ this.overlay = undefined
1392
+ return
1393
+ }
1371
1394
  this.overlay = 'history'
1372
1395
  await this.updateComplete
1373
1396
  await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
@@ -1418,19 +1441,80 @@ export class FigureEditor extends LitElement {
1418
1441
  }
1419
1442
  })
1420
1443
  }
1421
- /** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
1444
+ /** "Fasten it first", from the motion tab or a row: to the properties tab, the fastening window open on this part. */
1422
1445
  private async goFasten() {
1423
1446
  this.tab = 'properties'
1424
1447
  await this.updateComplete
1425
- const links = [...this.renderRoot.querySelectorAll<HTMLElement & { section?: string; showFastening?: () => Promise<void> }>('figure-links')].find(l => l.section === 'shape')
1426
- await links?.showFastening?.()
1448
+ this.openFastening()
1449
+ }
1450
+ private openFastening() {
1451
+ if (!this.selected || this.fastenPopup) return
1452
+ this.fastenError = ''
1453
+ this.fastenHover = null
1454
+ this.fastening = true
1455
+ const content = document.createElement('figure-fasten-dialog') as NonNullable<typeof this.fastenWindow>
1456
+ content.addEventListener('preview-command', e => this.commandPreview.ask((e as CustomEvent).detail))
1457
+ content.addEventListener('fasten-hover', e => this.hoverFastening((e as CustomEvent).detail))
1458
+ content.addEventListener('fasten-apply', e => this.applyFastening((e as CustomEvent).detail.action))
1459
+ content.addEventListener('fasten-cancel', () => this.closeFastening())
1460
+ this.fastenWindow = content
1461
+ this.tellFastening()
1462
+ const popup = openPopup(html`${content}`, { title: i18next.t('figure.label.fastening'), size: 'fit', backdrop: true, movable: true, closable: true, escapable: true })
1463
+ this.fastenPopup = popup
1464
+ /* Closed by its own X or Esc as well: the editor lets go of it the same way. */
1465
+ popup.result.then(() => this.fastenPopup === popup && this.fastenClosed())
1466
+ }
1467
+ private closeFastening() {
1468
+ const popup = this.fastenPopup
1469
+ this.fastenClosed()
1470
+ popup?.close()
1471
+ }
1472
+ private fastenClosed() {
1473
+ this.fastenPopup = undefined
1474
+ this.fastenWindow = undefined
1475
+ this.fastening = false
1476
+ this.fastenError = ''
1477
+ this.fastenHover = null
1478
+ }
1479
+ /** What the window shows is the editor's: the figure on screen, the part, and what the preview and a refusal said. */
1480
+ private tellFastening() {
1481
+ const content = this.fastenWindow
1482
+ const view = this.session?.read()
1483
+ if (!content || !view) return
1484
+ content.revision = this.shown()
1485
+ content.partId = this.selected
1486
+ content.locked = view.loading || !!view.error || view.saving || !view.context
1487
+ content.previewWords = this.commandPreview.words()
1488
+ content.error = this.fastenError
1489
+ }
1490
+ disconnectedCallback() {
1491
+ super.disconnectedCallback()
1492
+ this.closeFastening()
1493
+ }
1494
+ /** 「적용」: the command, one step; refused, the window stays open and says why. */
1495
+ private applyFastening(action: FigureAuthoringAction) {
1496
+ if (this.linkCommand(action)) this.closeFastening()
1497
+ else this.fastenError = this.error
1498
+ }
1499
+ /** A row of the window's list under the pointer or the arrows: the face of that part it would be fastened on, lit. */
1500
+ private hoverFastening(on: { part: string; face?: string } | null) {
1501
+ const shown = this.shown()
1502
+ let lit: FigureRenderPart | null = null
1503
+ try {
1504
+ lit = on?.face && shown ? faceHighlightOf(figurePartFaceOf(shown.asset, on.part, on.face, shown.inputs)) : null
1505
+ } catch {
1506
+ lit = null
1507
+ }
1508
+ this.fastenHover = lit
1427
1509
  }
1428
1510
  /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1429
1511
  private previewShown(): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
1430
1512
  const p = this.commandPreview.current()
1431
1513
  /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1432
1514
  if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }
1433
- return this.commandPreview.drawnOf(p)
1515
+ const drawn = this.commandPreview.drawnOf(p)
1516
+ /* A part in the fastening window's list under the pointer: its face lit in place of the one the command meets. */
1517
+ return this.fastenHover ? { ghost: drawn.ghost, highlight: this.fastenHover } : drawn
1434
1518
  }
1435
1519
  /**
1436
1520
  * What the author has in view, for the AI dock to hear with a request (ADR-0097 §5.6 item 5): the part selected and
@@ -1473,6 +1557,7 @@ export class FigureEditor extends LitElement {
1473
1557
  }
1474
1558
  updated(changed: PropertyValues<this>) {
1475
1559
  super.updated?.(changed)
1560
+ this.tellFastening()
1476
1561
  /* Nothing blocks release any more: the list under the bar closes, and opens fresh when something does again. */
1477
1562
  if (changed.has('releaseBlockers') && !this.releaseBlockers.length && this.blockersOpen) this.blockersOpen = false
1478
1563
  this.refusals.tell(changed)
@@ -1681,14 +1766,22 @@ export class FigureEditor extends LitElement {
1681
1766
  )}
1682
1767
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1683
1768
  <span slot="status" release-state>${this.releaseWords(view)}</span>
1684
- ${this.renderOverlayButton('history', 'history', 'figure.label.history', 'commit')}
1685
1769
  ${blockers.length
1686
1770
  ? html`<button slot="commit" release-blocked aria-expanded=${String(this.blockersOpen)} @click=${() => (this.blockersOpen = !this.blockersOpen)}>
1687
1771
  <md-icon>error</md-icon>${i18next.t('figure.button.release-blocked', { count: blockers.length })}
1688
1772
  </button>`
1689
1773
  : nothing}
1690
- <button slot="commit" publish ?disabled=${!view.id} title=${view.id ? '' : i18next.t('figure.text.save-the-figure-before-releasing')} @click=${this.goRelease}>${i18next.t('figure.button.release')}</button>
1691
- <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
1774
+ <button
1775
+ slot="commit"
1776
+ publish
1777
+ aria-pressed=${String(this.overlay === 'history')}
1778
+ ?disabled=${!view.id}
1779
+ title=${view.id ? '' : i18next.t('figure.text.save-the-figure-before-releasing')}
1780
+ @click=${this.goRelease}
1781
+ >
1782
+ <md-icon>publish</md-icon>${i18next.t('figure.button.release')}
1783
+ </button>
1784
+ <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}><md-icon>save</md-icon>${i18next.t('figure.button.save')}</button>
1692
1785
  </ox-canvas-bar>
1693
1786
  ${this.blockersOpen && blockers.length ? this.renderReleaseBlockers(blockers, asset, unavailable) : nothing}
1694
1787
  ${this.catalogOpen
@@ -0,0 +1,207 @@
1
+ import { FIGURE_FACES } from '@hatiolab/figure-model'
2
+ import type { FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
3
+
4
+ import { faceViewOf, modeOf, myFaceFor, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
5
+ import type { FastenChange } from './figure-fasten-drawing.js'
6
+ import {
7
+ alignOf,
8
+ alignRowsOf,
9
+ canFasten,
10
+ facesOf,
11
+ fasteningOf,
12
+ fastenGapOf,
13
+ hangsFrom,
14
+ heldBy,
15
+ ownFacesOf,
16
+ placeKeptOf,
17
+ type Alignment,
18
+ type Axis
19
+ } from './links.js'
20
+ import type { FigureRevision } from './revision.js'
21
+
22
+ /*
23
+ * A fastening as it is chosen, before it is done: what, which face, how, where across the face. The fastening window
24
+ * (`figure-fasten-dialog.ts`) holds one and changes it as the author chooses; the attach command is worked out from it
25
+ * only here, so the window, its preview and the panel's 「정렬 고치기」 send the same command for the same choice.
26
+ */
27
+
28
+ const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
29
+
30
+ export interface FastenForm {
31
+ face: FigureFace | 'centre'
32
+ toPlane: boolean
33
+ target: string
34
+ targetFace: FigureFace
35
+ facing: 'meet' | 'flush'
36
+ gap: number
37
+ /** The record's gap when it is worked out (a length that follows something): kept until a number is typed. */
38
+ gapKept: unknown
39
+ twist: number
40
+ align: Partial<Record<Axis, Alignment>>
41
+ alignMm: Partial<Record<Axis, number>>
42
+ alignInput: Partial<Record<Axis, string>>
43
+ }
44
+
45
+ export const freshFasten = (): FastenForm => ({
46
+ face: 'bottom',
47
+ toPlane: false,
48
+ target: '',
49
+ targetFace: 'top',
50
+ facing: 'meet',
51
+ gap: 0,
52
+ gapKept: undefined,
53
+ twist: 0,
54
+ align: {},
55
+ alignMm: {},
56
+ alignInput: {}
57
+ })
58
+
59
+ /** The part's own fastening as a form, or a fresh one: a window opens on what the part has (walk 1, 2026-09-26). */
60
+ export function fastenFormOf(revision: FigureRevision, part: string): FastenForm {
61
+ const own = fasteningOf(revision, part)
62
+ return own && !own.atOrigin ? ({ ...freshFasten(), ...own } as FastenForm) : freshFasten()
63
+ }
64
+
65
+ /** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
66
+ export function fastenTargetsOf(revision: FigureRevision, part: string): Array<{ part: string; loops: boolean }> {
67
+ const asset = revision.asset
68
+ return (
69
+ asset.appearance
70
+ .map(look => look.target)
71
+ .filter(one => one !== part && canFasten(asset, one))
72
+ /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
73
+ .map(one => ({ part: one, loops: hangsFrom(asset, one, part) }))
74
+ )
75
+ }
76
+
77
+ /**
78
+ * A new aim: the plane, another part, or another face of the same part. Where on the face is filled in as where the
79
+ * part stands now, so fastening it keeps it there (`placeKeptOf`); the author changes it from there.
80
+ */
81
+ export function aimFasten(
82
+ revision: FigureRevision,
83
+ part: string,
84
+ form: FastenForm,
85
+ to: { plane: true } | { part: string } | { targetFace: FigureFace }
86
+ ): FastenForm {
87
+ const f: FastenForm =
88
+ 'plane' in to
89
+ ? { ...form, toPlane: true, target: '' }
90
+ : 'part' in to
91
+ ? { ...form, toPlane: false, target: to.part }
92
+ : { ...form, targetFace: to.targetFace }
93
+ const theirFaces = f.target ? facesOf(revision.asset, f.target) : FACES
94
+ const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
95
+ const aim: FastenTarget | undefined = f.toPlane
96
+ ? 'plane'
97
+ : f.target
98
+ ? { part: f.target, face: targetFace }
99
+ : undefined
100
+ return { ...f, ...(aim ? placeKeptOf(revision, part, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) }
101
+ }
102
+
103
+ /** A change the drawing asks: a face, a way, a gap or a place across the face. */
104
+ export function fastenChanged(
105
+ revision: FigureRevision,
106
+ part: string,
107
+ form: FastenForm,
108
+ change: FastenChange
109
+ ): FastenForm {
110
+ const f = form
111
+ if ('targetFace' in change) return aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace })
112
+ if ('mode' in change)
113
+ return {
114
+ ...f,
115
+ face: change.mode === 'centre' ? 'centre' : f.face === 'centre' ? 'bottom' : f.face,
116
+ facing: change.mode === 'flush' ? 'flush' : 'meet'
117
+ }
118
+ if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined }
119
+ return {
120
+ ...f,
121
+ align: { ...f.align, [change.axis]: change.how },
122
+ alignMm: change.how === 'mm' ? { ...f.alignMm, [change.axis]: change.mm ?? 0 } : f.alignMm
123
+ }
124
+ }
125
+
126
+ /** What a form comes to: the face aimed at, the part's own face, the way, the drawing of the face and the command. */
127
+ export interface FastenPlan {
128
+ /** Nothing is aimed at until a part or the plane is chosen. */
129
+ aim?: FastenTarget
130
+ theirFaces: string[]
131
+ targetFace: FigureFace
132
+ mode: FastenMode
133
+ /** The part's own face: not chosen, the target's face and the way give it (user 2026-10-01; the kernel takes no other). */
134
+ myFace: FigureFace | 'centre'
135
+ view?: FaceView
136
+ /** The axes across the face asked in rows: where the drawing cannot set the place. */
137
+ alignRows: { axis: Axis; edges: boolean }[]
138
+ action?: FigureAuthoringAction
139
+ /** The face the command would meet, lit on the canvas while it is tried. */
140
+ face?: { part: string; face: string }
141
+ }
142
+
143
+ export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
144
+ const asset = revision.asset
145
+ const mineFaces = ownFacesOf(asset, part, f.toPlane)
146
+ const theirFaces = f.target ? facesOf(asset, f.target) : FACES
147
+ const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
148
+ const aim: FastenTarget | undefined = f.toPlane
149
+ ? 'plane'
150
+ : f.target
151
+ ? { part: f.target, face: targetFace }
152
+ : undefined
153
+ const mode: FastenMode = f.toPlane ? 'meet' : modeOf(f.face, f.facing)
154
+ const derived = aim ? myFaceFor(revision, part, aim, mode) : undefined
155
+ const myFace = derived ?? (mineFaces.includes(f.face) ? f.face : mineFaces[0]!)
156
+ if (!aim) return { theirFaces, targetFace, mode, myFace, alignRows: [] }
157
+ const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
158
+ const view = faceViewOf(revision, part, aim)
159
+ const action = {
160
+ kind: 'attach',
161
+ part,
162
+ face: myFace,
163
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
164
+ ...(f.toPlane ? {} : { facing: f.facing }),
165
+ ...fastenGapOf(f),
166
+ twist: f.twist,
167
+ ...(align ? { align } : {}),
168
+ replace: !!heldBy(asset, part)
169
+ } as FigureAuthoringAction
170
+ return {
171
+ aim,
172
+ theirFaces,
173
+ targetFace,
174
+ mode,
175
+ myFace,
176
+ view,
177
+ alignRows: alignRowsOf(asset, part, aim, view, !!f.twist, f.alignInput),
178
+ action,
179
+ ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } })
180
+ }
181
+ }
182
+
183
+ type Say = (key: string, params?: Record<string, string>) => string
184
+
185
+ /** 「lid 의 아랫면을 bin 의 윗면에 맞닿게」: what the fastening is, in one line, the part's own face said as a result. */
186
+ export function fastenSentenceOf(
187
+ part: string,
188
+ form: FastenForm,
189
+ plan: FastenPlan,
190
+ planeWord: string,
191
+ say: Say
192
+ ): string {
193
+ const to = form.toPlane ? planeWord : `${form.target}의 ${say('figure.label.face-' + plan.targetFace)}`
194
+ return say(`figure.text.fasten-sentence-${plan.mode}`, { part, face: say('figure.label.face-' + plan.myFace), to })
195
+ }
196
+
197
+ /** What the folded details hold that is not their default, said while folded; nothing for the default. */
198
+ export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
199
+ return [
200
+ ...(form.gapKept !== undefined
201
+ ? [say('figure.text.fasten-summary-gap-worked-out')]
202
+ : form.gap
203
+ ? [say('figure.text.fasten-summary-gap', { mm: String(form.gap) })]
204
+ : []),
205
+ ...(form.twist ? [say('figure.text.fasten-summary-twist', { degrees: String(form.twist) })] : [])
206
+ ]
207
+ }
@@ -190,7 +190,9 @@ export function screenOf(axes: readonly [Axis, Axis]): { h: Axis; v: Axis; sign:
190
190
  * Where the fastening drawing writes its two names so neither lies over the other (user 2026-10-16: 「roller」 over the
191
191
  * roller's thin bar, and 「frame 윗면 2000 × 600」 cut by it). The face's name stands above the face, inside its top edge
192
192
  * only when the face fills the drawing. The part's name stands in the part when the part is wide and tall enough for it,
193
- * else just under it -- above it when the drawing ends first. Widths are estimated: 7 px a letter at 11-12 px.
193
+ * else just under it -- above it when the drawing ends first. Under a part as tall as the face, just under it is the
194
+ * face's own edge, and the name lay on that line (2026-10-18, the roller in the fastening window): it goes under the edge.
195
+ * Widths are estimated: 7 px a letter at 11-12 px; a line of it stands 11 px over its baseline and 3 under.
194
196
  */
195
197
  export function fastenLabelsOf(
196
198
  face: { x: number; y: number; w: number; h: number },
@@ -202,5 +204,7 @@ export function fastenLabelsOf(
202
204
  const fits = part.w >= partName.length * 7 + 8 && part.h >= 16
203
205
  if (fits) return { face: facePlace, part: { x: part.x + part.w / 2, y: part.y + part.h / 2 + 4, inside: true } }
204
206
  const below = part.y + part.h + 14
207
+ const edge = face.y + face.h
208
+ if (below - 11 < edge + 2 && below + 3 > edge - 2 && edge + 14 <= height - 4) return { face: facePlace, part: { x: part.x + part.w / 2, y: edge + 14, inside: false } }
205
209
  return { face: facePlace, part: { x: part.x + part.w / 2, y: below <= height - 20 ? below : part.y - 5, inside: false } }
206
210
  }