@things-factory/figure-ui 10.1.204 → 10.1.206

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 (116) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +23 -22
  3. package/client/modeller/editor.ts +217 -184
  4. package/client/modeller/fasten-form.ts +130 -12
  5. package/client/modeller/figure-fasten-dialog.ts +45 -11
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +6 -6
  9. package/client/modeller/figure-section-editor.ts +6 -6
  10. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  11. package/client/modeller/gizmo.ts +22 -1
  12. package/client/modeller/keys.ts +10 -1
  13. package/client/modeller/links.ts +79 -46
  14. package/client/modeller/palette.ts +1 -1
  15. package/client/modeller/product-session.ts +16 -4
  16. package/client/modeller/refusal-teller.ts +3 -25
  17. package/client/modeller/refusals.ts +19 -26
  18. package/client/modeller/section-drawing.ts +7 -7
  19. package/client/modeller/size-drag.ts +112 -0
  20. package/client/modeller/slot-layout-host.ts +3 -3
  21. package/client/modeller/stage-model.ts +6 -9
  22. package/client/pages/figure-modeller-page.ts +1 -4
  23. package/dist-client/modeller/canvas.d.ts +24 -3
  24. package/dist-client/modeller/canvas.js +96 -11
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/editor-styles.js +23 -22
  27. package/dist-client/modeller/editor-styles.js.map +1 -1
  28. package/dist-client/modeller/editor.d.ts +35 -35
  29. package/dist-client/modeller/editor.js +221 -193
  30. package/dist-client/modeller/editor.js.map +1 -1
  31. package/dist-client/modeller/fasten-form.d.ts +46 -4
  32. package/dist-client/modeller/fasten-form.js +120 -11
  33. package/dist-client/modeller/fasten-form.js.map +1 -1
  34. package/dist-client/modeller/figure-fasten-dialog.d.ts +18 -4
  35. package/dist-client/modeller/figure-fasten-dialog.js +49 -12
  36. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  37. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  38. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  39. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  40. package/dist-client/modeller/figure-links.d.ts +1 -1
  41. package/dist-client/modeller/figure-links.js +2 -2
  42. package/dist-client/modeller/figure-links.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.js +4 -4
  44. package/dist-client/modeller/figure-preview.js.map +1 -1
  45. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  46. package/dist-client/modeller/figure-section-editor.js +4 -4
  47. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  48. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  49. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  50. package/dist-client/modeller/gizmo.d.ts +18 -1
  51. package/dist-client/modeller/gizmo.js +19 -0
  52. package/dist-client/modeller/gizmo.js.map +1 -1
  53. package/dist-client/modeller/keys.d.ts +1 -1
  54. package/dist-client/modeller/keys.js +6 -0
  55. package/dist-client/modeller/keys.js.map +1 -1
  56. package/dist-client/modeller/links.d.ts +30 -24
  57. package/dist-client/modeller/links.js +67 -42
  58. package/dist-client/modeller/links.js.map +1 -1
  59. package/dist-client/modeller/palette.js +1 -1
  60. package/dist-client/modeller/palette.js.map +1 -1
  61. package/dist-client/modeller/product-session.d.ts +15 -13
  62. package/dist-client/modeller/product-session.js +15 -5
  63. package/dist-client/modeller/product-session.js.map +1 -1
  64. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  65. package/dist-client/modeller/refusal-teller.js +3 -22
  66. package/dist-client/modeller/refusal-teller.js.map +1 -1
  67. package/dist-client/modeller/refusals.d.ts +6 -14
  68. package/dist-client/modeller/refusals.js +16 -27
  69. package/dist-client/modeller/refusals.js.map +1 -1
  70. package/dist-client/modeller/section-drawing.d.ts +3 -4
  71. package/dist-client/modeller/section-drawing.js +10 -9
  72. package/dist-client/modeller/section-drawing.js.map +1 -1
  73. package/dist-client/modeller/size-drag.d.ts +38 -0
  74. package/dist-client/modeller/size-drag.js +85 -0
  75. package/dist-client/modeller/size-drag.js.map +1 -0
  76. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  77. package/dist-client/modeller/slot-layout-host.js +3 -3
  78. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  79. package/dist-client/modeller/stage-model.d.ts +5 -7
  80. package/dist-client/modeller/stage-model.js +6 -6
  81. package/dist-client/modeller/stage-model.js.map +1 -1
  82. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  83. package/dist-client/pages/figure-modeller-page.js +1 -6
  84. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  85. package/dist-client/route.d.ts +1 -1
  86. package/dist-client/tsconfig.tsbuildinfo +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +7 -7
  89. package/test/align.test.ts +7 -11
  90. package/test/browser/acceptance-page.js +11 -1
  91. package/test/browser/editing.check.mjs +17 -13
  92. package/test/browser/links-page.js +2 -2
  93. package/test/browser/links.check.mjs +124 -69
  94. package/test/browser/modeller-page.js +30 -18
  95. package/test/browser/modeller-revision.check.mjs +39 -0
  96. package/test/browser/modeller.check.mjs +77 -26
  97. package/test/browser/parts.check.mjs +49 -0
  98. package/test/browser/screen-page.js +0 -2
  99. package/test/browser/screen.check.mjs +11 -13
  100. package/test/browser/section.check.mjs +29 -2
  101. package/test/fasten-form.test.ts +102 -1
  102. package/test/gizmo.test.ts +25 -16
  103. package/test/keys.test.ts +11 -0
  104. package/test/links.test.ts +46 -36
  105. package/test/refusals.test.ts +17 -21
  106. package/test/resize-with-placement.test.ts +14 -13
  107. package/test/revision.test.ts +3 -2
  108. package/test/section-editor.test.ts +9 -10
  109. package/test/size-drag.test.ts +114 -0
  110. package/test/slot-layout-host.test.ts +13 -8
  111. package/translations/en.json +32 -34
  112. package/translations/es.json +32 -34
  113. package/translations/ja.json +32 -34
  114. package/translations/ko.json +32 -34
  115. package/translations/ms.json +32 -34
  116. package/translations/zh.json +32 -34
@@ -1,5 +1,5 @@
1
- import { FIGURE_FACES, figurePlaneFacingOf } from '@hatiolab/figure-model'
2
- import type { FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureFastenKeptGapOf, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
2
+ import type { FigureAuthoringAction, FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
3
3
 
4
4
  import { faceViewOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
5
5
  import type { FastenChange } from './figure-fasten-drawing.js'
@@ -14,10 +14,12 @@ import {
14
14
  heldBy,
15
15
  mountingPlaneOf,
16
16
  placeKeptOf,
17
+ surfaceOf,
17
18
  type Alignment,
18
19
  type Axis
19
20
  } from './links.js'
20
21
  import type { FigureRevision } from './revision.js'
22
+ import { ownerOf } from './fields.js'
21
23
 
22
24
  /*
23
25
  * A fastening as it is chosen, before it is done: to what, which face, how, where across the face. The fastening window
@@ -36,9 +38,17 @@ export interface FastenForm {
36
38
  gap: number
37
39
  /** The record's gap when it is worked out (a length that follows something): kept until a number is typed. */
38
40
  gapKept: unknown
41
+ /**
42
+ * Where the gap came from (ADR-0140 stage 4, 「현재 상태로 붙이기」): `kept` -- the distance the part stands at, worked
43
+ * out again whenever where or how it is fastened changes, so fastening does not move it; `typed` -- the author's
44
+ * number, left as it is; `pressed` -- 0, the part put where the canvas was pressed.
45
+ */
46
+ gapFrom: 'kept' | 'typed' | 'pressed'
39
47
  align: Partial<Record<Axis, Alignment>>
40
48
  alignMm: Partial<Record<Axis, number>>
41
49
  alignInput: Partial<Record<Axis, string>>
50
+ /** On a point of the target's curved surface instead of a face (ADR-0140 3.9): where, as the record says it. */
51
+ surface?: FigureSurfacePlace
42
52
  }
43
53
 
44
54
  export const freshFasten = (): FastenForm => ({
@@ -48,6 +58,7 @@ export const freshFasten = (): FastenForm => ({
48
58
  facing: 'meet',
49
59
  gap: 0,
50
60
  gapKept: undefined,
61
+ gapFrom: 'kept',
51
62
  align: {},
52
63
  alignMm: {},
53
64
  alignInput: {}
@@ -56,7 +67,9 @@ export const freshFasten = (): FastenForm => ({
56
67
  /** 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). */
57
68
  export function fastenFormOf(revision: FigureRevision, part: string): FastenForm {
58
69
  const own = fasteningOf(revision, part)
59
- return own && !own.atOrigin ? ({ ...freshFasten(), ...own } as FastenForm) : freshFasten()
70
+ if (!own || own.atOrigin) return freshFasten()
71
+ /* A gap the record works out is the author's; a number is where the part stands, so aiming again keeps it there. */
72
+ return { ...freshFasten(), ...own, gapFrom: own.gapKept !== undefined ? 'typed' : 'kept' } as FastenForm
60
73
  }
61
74
 
62
75
  /** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
@@ -72,15 +85,38 @@ export function fastenTargetsOf(revision: FigureRevision, part: string): Array<{
72
85
  }
73
86
 
74
87
  /**
75
- * A new aim: the plane, another part, or another face of the same part. Where on the face is filled in as where the
76
- * part stands now, so fastening it keeps it there (`placeKeptOf`); the author changes it from there.
88
+ * The point of a target's curved surface on the side the part stands on now (ADR-0140 3.9): the kernel's
89
+ * (`figureSurfaceKeptPlaceOf`), or, the part standing on its axis, the surface's first point.
90
+ */
91
+ export function surfaceKeptOf(revision: FigureRevision, part: string, target: string): FigureSurfacePlace | undefined {
92
+ const surface = surfaceOf(revision.asset, target)
93
+ if (!surface) return undefined
94
+ return (
95
+ figureSurfaceKeptPlaceOf(revision.asset, part, target, { ...revision.inputs }) ??
96
+ (surface.form === 'around' ? { around: 0, along: 'centre' } : { toward: { x: 0, y: 1, z: 0 } })
97
+ )
98
+ }
99
+
100
+ /**
101
+ * A new aim: the plane, another part, another face of the same part, or a point of its curved surface. Where on the
102
+ * face -- or the surface -- is filled in as where the part stands now, so fastening it keeps it there (`placeKeptOf`,
103
+ * `surfaceKeptOf`); the author changes it from there. A part aimed at a sphere, or beside a cylinder's side within its
104
+ * length, is aimed at the surface: it is fastened as it stands (ADR-0140 3.6).
77
105
  */
78
106
  export function aimFasten(
79
107
  revision: FigureRevision,
80
108
  part: string,
81
109
  form: FastenForm,
82
- to: { plane: true } | { part: string } | { targetFace: FigureFace }
110
+ to: { plane: true } | { part: string } | { targetFace: FigureFace } | { surface: true }
83
111
  ): FastenForm {
112
+ if ('surface' in to) return { ...form, surface: surfaceKeptOf(revision, part, form.target), align: {}, alignMm: {}, alignInput: {} }
113
+ if ('part' in to) {
114
+ /* Beside its curved surface -- a sphere's anywhere, a cylinder's side within its length -- or with no face at all. */
115
+ const beside = figureSurfaceKeptPlaceOf(revision.asset, part, to.part, { ...revision.inputs })
116
+ const kept = beside ?? (!facesOf(revision.asset, to.part).length ? surfaceKeptOf(revision, part, to.part) : undefined)
117
+ if (kept) return { ...form, toPlane: false, target: to.part, surface: kept, align: {}, alignMm: {}, alignInput: {} }
118
+ }
119
+ form = { ...form, surface: undefined }
84
120
  /* Aimed at the plane, it meets it toward the side the placement uses -- below a ceiling -- until the author says (ADR-0140 3.11). */
85
121
  const f: FastenForm =
86
122
  'plane' in to
@@ -106,9 +142,11 @@ export function fastenChanged(
106
142
  change: FastenChange
107
143
  ): FastenForm {
108
144
  const f = form
109
- if ('targetFace' in change) return aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace })
110
- if ('mode' in change) return { ...f, facing: change.mode }
111
- if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined }
145
+ if ('targetFace' in change) return keptGapOf(revision, part, aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace }))
146
+ if ('onSurface' in change) return keptGapOf(revision, part, aimFasten(revision, part, f, { surface: true }))
147
+ if ('surface' in change) return keptGapOf(revision, part, { ...f, surface: change.surface })
148
+ if ('mode' in change) return keptGapOf(revision, part, { ...f, facing: change.mode })
149
+ if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined, gapFrom: 'typed' }
112
150
  return {
113
151
  ...f,
114
152
  align: { ...f.align, [change.axis]: change.how },
@@ -116,6 +154,29 @@ export function fastenChanged(
116
154
  }
117
155
  }
118
156
 
157
+ /**
158
+ * The form with the gap that keeps the part where it stands (ADR-0140 stage 4, user 2026-10-20 「현재 상태로 붙이기」):
159
+ * figure-model's `figureFastenKeptGapOf` for what it is aimed at and how it meets it. A gap the author typed, or the 0 a
160
+ * canvas press puts, is left as it is. Nothing aimed at yet, the form is as it was.
161
+ */
162
+ export function keptGapOf(revision: FigureRevision, part: string, form: FastenForm): FastenForm {
163
+ if (form.gapFrom !== 'kept') return form
164
+ const asset = revision.asset
165
+ const theirFaces = form.target ? facesOf(asset, form.target) : FACES
166
+ const targetFace = (theirFaces.includes(form.targetFace) ? form.targetFace : theirFaces[0]) as FigureFace
167
+ const planeWay = figurePlaneFacingOf(asset)
168
+ const mode: FastenMode = form.toPlane ? (mountingPlaneOf(asset).facings.includes(form.facing) ? form.facing : (planeWay ?? 'meet')) : form.facing
169
+ const to = form.toPlane
170
+ ? ('plane' as const)
171
+ : form.target && form.surface && surfaceOf(asset, form.target)
172
+ ? { part: form.target, surface: form.surface }
173
+ : form.target
174
+ ? { part: form.target, face: targetFace }
175
+ : undefined
176
+ if (!to) return form
177
+ return { ...form, gap: figureFastenKeptGapOf(asset, part, to, mode, { ...revision.inputs }), gapKept: undefined }
178
+ }
179
+
119
180
  /** What a form comes to: the face aimed at, the way, the drawing of the face and the command. */
120
181
  export interface FastenPlan {
121
182
  /** Nothing is aimed at until a part or the plane is chosen. */
@@ -129,6 +190,8 @@ export interface FastenPlan {
129
190
  action?: FigureAuthoringAction
130
191
  /** The face the command would meet, lit on the canvas while it is tried. */
131
192
  face?: { part: string; face: string }
193
+ /** On a point of the target's curved surface: which surface, about which axis, and where (ADR-0140 3.9). */
194
+ surface?: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }
132
195
  }
133
196
 
134
197
  export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
@@ -143,6 +206,19 @@ export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenFo
143
206
  /* The plane takes a part standing on it or hanging below it, a wall only one standing out of it (ADR-0140 3.11). */
144
207
  const planeWay = figurePlaneFacingOf(asset)
145
208
  const mode: FastenMode = f.toPlane ? (mountingPlaneOf(asset).facings.includes(f.facing) ? f.facing : (planeWay ?? 'meet')) : f.facing
209
+ const surface = !f.toPlane && f.target && f.surface ? surfaceOf(asset, f.target) : undefined
210
+ if (surface) {
211
+ /* On the surface: where is the surface's own words, so no alignment goes with it (ADR-0140 3.9). */
212
+ const action = {
213
+ kind: 'attach',
214
+ part,
215
+ to: { part: f.target, surface: f.surface },
216
+ facing: mode,
217
+ ...fastenGapOf(f),
218
+ replace: !!heldBy(asset, part)
219
+ } as FigureAuthoringAction
220
+ return { theirFaces, targetFace, mode, alignRows: [], action, surface: { ...surface, place: f.surface! } }
221
+ }
146
222
  if (!aim) return { theirFaces, targetFace, mode, alignRows: [] }
147
223
  const align = alignOf(asset, aim, f.align, f.alignMm, f.alignInput)
148
224
  const view = faceViewOf(revision, part, aim, mode)
@@ -177,17 +253,59 @@ export function fastenSentenceOf(
177
253
  planeWord: string,
178
254
  say: Say
179
255
  ): string {
180
- const to = form.toPlane ? planeWord : `${form.target}의 ${say('figure.label.face-' + plan.targetFace)}`
256
+ const where = plan.surface ? say('figure.label.surface-' + plan.surface.form) : say('figure.label.face-' + plan.targetFace)
257
+ const to = form.toPlane ? planeWord : `${form.target}의 ${where}`
181
258
  return say(`figure.text.fasten-sentence-${plan.mode}`, { to })
182
259
  }
183
260
 
184
- /** What the folded details hold that is not their default, said while folded; nothing for the default. */
261
+ /**
262
+ * What the folded details hold that is not their default, said while folded; nothing for the default. The gap's default
263
+ * is the one it starts at -- the distance the part stands at, or 0 where the canvas was pressed -- so only a number typed
264
+ * is said.
265
+ */
185
266
  export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
186
267
  return [
187
268
  ...(form.gapKept !== undefined
188
269
  ? [say('figure.text.fasten-summary-gap-worked-out')]
189
- : form.gap
270
+ : form.gapFrom === 'typed' && form.gap
190
271
  ? [say('figure.text.fasten-summary-gap', { mm: String(form.gap) })]
191
272
  : [])
192
273
  ]
193
274
  }
275
+
276
+ /**
277
+ * A part pressed on the canvas while the window is open (ADR-0140 stage 4): aimed at, on the face or the point pressed,
278
+ * its middle where it was pressed -- the kernel's reading of the press (`figureFastenTargetAt`), the point and normal brought into that part's own
279
+ * frame by the pose its body is drawn at (`at.id`, a row's copy too). The part itself, one hanging from it, or one with
280
+ * nothing to fasten to is not aimed at; a press on a curve there is no fastening to aims at the part alone. Each says
281
+ * why, by its words' key.
282
+ */
283
+ /* Put where the canvas was pressed: on the face or the surface there, with no gap (ADR-0140 stage 4). */
284
+ const PRESSED = { gap: 0, gapKept: undefined, gapFrom: 'pressed' as const }
285
+
286
+ export function aimFastenAt(
287
+ revision: FigureRevision,
288
+ part: string,
289
+ form: FastenForm,
290
+ at: { id: string; point?: readonly number[]; normal?: readonly number[] }
291
+ ): { form?: FastenForm; note?: 'aim-self' | 'aim-cannot' | 'target-would-loop' | 'aim-not-here'; part: string } {
292
+ const target = ownerOf(at.id)
293
+ const offered = fastenTargetsOf(revision, part).find(t => t.part === target)
294
+ if (target === part) return { note: 'aim-self', part: target }
295
+ if (!offered) return { note: 'aim-cannot', part: target }
296
+ if (offered.loops) return { note: 'target-would-loop', part: target }
297
+ const aimed = aimFasten(revision, part, form, { part: target })
298
+ const pose = (revision.geometry as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>).find(g => g.id === at.id)?.pose
299
+ if (!pose || !at.point || !at.normal) return { form: aimed, part: target }
300
+ /* Into the part's own frame: rᵀ (p − t), and the normal by rᵀ. */
301
+ const own = (v: readonly number[], from: readonly number[] = [0, 0, 0]) => [0, 1, 2].map(c => [0, 1, 2].reduce((sum, i) => sum + pose.r[i]![c]! * (v[i]! - from[i]!), 0))
302
+ const lands = figureFastenTargetAt(revision.asset, target, own(at.point, pose.t), own(at.normal), { ...revision.inputs })
303
+ if ('face' in lands) {
304
+ /* On the face where it was pressed, as a point of a curved surface is: its middle there. */
305
+ const onFace = aimFasten(revision, part, { ...aimed, surface: undefined }, { targetFace: lands.face as FigureFace })
306
+ const at = lands.at as Partial<Record<Axis, number>>
307
+ return { form: { ...onFace, ...PRESSED, align: Object.fromEntries(Object.keys(at).map(a => [a, 'mm'])) as FastenForm['align'], alignMm: { ...at }, alignInput: {} }, part: target }
308
+ }
309
+ if ('surface' in lands) return { form: { ...aimed, ...PRESSED, toPlane: false, target, surface: lands.surface, align: {}, alignMm: {}, alignInput: {} }, part: target }
310
+ return { form: aimed, note: 'aim-not-here', part: target }
311
+ }
@@ -15,6 +15,8 @@ import type { FastenChange } from './figure-fasten-drawing.js'
15
15
  import { turnsInPlace, type FastenMode } from './fasten-geometry.js'
16
16
  import {
17
17
  aimFasten,
18
+ aimFastenAt,
19
+ keptGapOf,
18
20
  fastenChanged,
19
21
  fastenDetailWordsOf,
20
22
  fastenFormOf,
@@ -23,16 +25,17 @@ import {
23
25
  fastenTargetsOf,
24
26
  type FastenForm
25
27
  } from './fasten-form.js'
26
- import { fieldWordsOf, hasInstanceSize, heldBy, mountingPlaneOf, type Alignment, type Axis } from './links.js'
28
+ import { fieldWordsOf, hasInstanceSize, heldBy, mountingPlaneOf, surfaceOf, type Alignment, type Axis } from './links.js'
27
29
  import type { FigureRevision } from './revision.js'
28
30
 
29
31
  /** The target list's entry for the plane the figure stands by. */
30
32
  const PLANE = '__plane'
31
33
 
32
34
  /**
33
- * 붙이기 창: what the selected part is fastened to, on which face, how (user 2026-10-17: 「속성에는 심플하고 직관적인
34
- * 결과 위주로, 편집은 팝업에서 넓은 공간을 써서」). The editor stands it over the side panel with its title and close,
35
- * where the canvas stays in sight (2026-10-19: the standard popup, always modal, hid the 3D it was choosing for).
35
+ * 붙이기 창: what the selected part is fastened to, on which face, how -- chosen with room to see it, in the standard
36
+ * popup the editor opens (`openPopup`: its title, its close, moved by its head; no backdrop, so the canvas can be
37
+ * pressed while it is open) (user 2026-10-17: 「속성에는 심플하고 직관적인 결과 위주로, 편집은 팝업에서 넓은 공간을
38
+ * 써서」; 2026-10-18: the standard popup, not a window of its own; put back 2026-10-20).
36
39
  *
37
40
  * Everything chosen here is tried, not done: the command it comes to is told to the editor as it changes
38
41
  * (`preview-command`), and the canvas draws it. 「적용」 sends it once (`fasten-apply`), one step of the history;
@@ -46,11 +49,16 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
46
49
  SidePanelStyles,
47
50
  CommonPopupStyles,
48
51
  css`
52
+ /*
53
+ The popup holds it at its own height and cuts what is past the screen: the window scrolls itself, within the
54
+ screen less the popup's head and margins, 취소 · 적용 kept at its foot (below).
55
+ */
49
56
  :host {
50
57
  display: grid;
51
58
  gap: 16px;
52
59
  padding: var(--popup-padding, 16px);
53
60
  overflow: auto;
61
+ max-height: calc(100dvh - 32px - 56px);
54
62
  }
55
63
  /* The result in one line, at the top: it changes with every choice below it. */
56
64
  p[fasten-sentence] {
@@ -104,7 +112,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
104
112
  margin: 0;
105
113
  color: var(--md-sys-color-error);
106
114
  }
107
- /* 취소 · 적용 stay in sight at the window's foot when what is above them is taller than the panel. */
115
+ /* 취소 · 적용 stay in sight at the window's foot when what is above them is taller than the screen. */
108
116
  ox-popup-actions {
109
117
  position: sticky;
110
118
  bottom: calc(-1 * var(--popup-padding, 16px));
@@ -126,6 +134,8 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
126
134
  @property() error = ''
127
135
 
128
136
  @state() private form?: FastenForm
137
+ /** Why a press on the canvas aimed at nothing, or only at the part (ADR-0140 stage 4); cleared by the next choice. */
138
+ @state() private aimNote = ''
129
139
  /** The command the part's own fastening comes to, as opened: tried unchanged, nothing new is said of it. */
130
140
  private opened?: string
131
141
  private told = ''
@@ -195,12 +205,17 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
195
205
  .alignMm=${f.alignMm}
196
206
  .gap=${f.gap}
197
207
  .partName=${this.partId}
208
+ .surfaceForm=${f.toPlane || !f.target ? '' : (surfaceOf(asset, f.target)?.form ?? '')}
209
+ .surface=${plan.surface}
198
210
  ?locked=${this.locked}
199
- @fasten-change=${(e: CustomEvent<FastenChange>) => (this.form = fastenChanged(revision, this.partId, this.form!, e.detail))}
211
+ @fasten-change=${(e: CustomEvent<FastenChange>) => {
212
+ this.aimNote = ''
213
+ this.form = fastenChanged(revision, this.partId, this.form!, e.detail)
214
+ }}
200
215
  ></figure-fasten-drawing>`
201
216
  return html`
202
217
  ${
203
- plan.aim
218
+ plan.aim || plan.surface
204
219
  ? html`<p fasten-sentence role="status">
205
220
  ${fastenSentenceOf(f, plan, planeWord, (key, params) => i18next.t(key, params))}
206
221
  </p>`
@@ -211,13 +226,15 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
211
226
  ${this.renderTargets(revision, f, planeWord)}
212
227
  </div>
213
228
  ${
214
- plan.aim
229
+ plan.aim || plan.surface
215
230
  ? html`<div question="face">
216
231
  <h4>
217
232
  ${
218
233
  /* The face asked is the target's, named: the part meets it by its real shape, by no face of its own. */ f.toPlane
219
234
  ? i18next.t('figure.label.fasten-face-on-plane', { plane: planeWord })
220
- : i18next.t('figure.label.fasten-face-on-part', { part: f.target })
235
+ : /* A sphere has no face to choose: where on its surface. */ plan.theirFaces.length
236
+ ? i18next.t('figure.label.fasten-face-on-part', { part: f.target })
237
+ : i18next.t('figure.label.fasten-face-on-surface', { part: f.target })
221
238
  }
222
239
  </h4>
223
240
  ${drawing('face')}
@@ -244,7 +261,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
244
261
  step="any"
245
262
  .value=${f.gapKept !== undefined ? '' : String(f.gap)}
246
263
  placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
247
- @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined })}
264
+ @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined, gapFrom: 'typed' })}
248
265
  />
249
266
  </div>
250
267
  </details>
@@ -252,6 +269,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
252
269
  ${/* The one it has, tried unchanged: that it stays where it is goes without saying. */ JSON.stringify(plan.action ?? null) === this.opened ? nothing : this.renderPreviewWords()}`
253
270
  : nothing
254
271
  }
272
+ ${this.aimNote ? html`<p hint aim-note role="status">${this.aimNote}</p>` : nothing}
255
273
  ${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
256
274
  <ox-popup-actions>
257
275
  <md-text-button slot="cancel" cancel @click=${this.cancel}>${i18next.t('figure.button.cancel')}</md-text-button>
@@ -310,9 +328,23 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
310
328
 
311
329
  private aim(part: string) {
312
330
  if (this.locked || !this.revision || !this.form) return
331
+ this.aimNote = ''
313
332
  const chosen = this.form.toPlane ? PLANE : this.form.target
314
333
  if (part === chosen) return
315
- this.form = aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part })
334
+ this.form = keptGapOf(this.revision, this.partId, aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part }))
335
+ }
336
+
337
+ /**
338
+ * A part pressed on the canvas while the window is open (ADR-0140 stage 4): aimed at, on the face or the point pressed
339
+ * -- the kernel's reading of the press (`figureFastenTargetAt`), the point and normal brought into that part's own
340
+ * frame by the pose it is drawn at. The part itself, or one hanging from it, is not aimed at; and a press on a curve
341
+ * there is no fastening to aims at the part alone. Each is said.
342
+ */
343
+ aimAt(at: { id: string; point?: number[]; normal?: number[] }) {
344
+ if (this.locked || !this.revision || !this.form) return
345
+ const aimed = aimFastenAt(this.revision, this.partId, this.form, at)
346
+ this.aimNote = aimed.note ? i18next.t(`figure.text.${aimed.note}`, { part: aimed.part, mine: this.partId }) : ''
347
+ if (aimed.form) this.form = aimed.form
316
348
  }
317
349
 
318
350
  /** The part under the pointer or the focus in the list, for the canvas to light; the plane lights nothing. */
@@ -410,6 +442,8 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
410
442
  this.setAttribute('role', 'dialog')
411
443
  this.setAttribute('aria-label', i18next.t('figure.label.fastening'))
412
444
  this.addEventListener('keydown', this.onKey)
445
+ /* The popup's fitting width is the overlay's own setting; the window asks it for room for the face and the way side by side. */
446
+ this.closest<HTMLElement>('ox-floating-overlay')?.style.setProperty('--overlay-center-fit-width', '640px')
413
447
  }
414
448
 
415
449
  private onKey = async (e: KeyboardEvent) => {