@things-factory/figure-ui 10.1.204 → 10.1.205
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.
- package/client/modeller/canvas.ts +91 -9
- package/client/modeller/editor-styles.ts +7 -22
- package/client/modeller/editor.ts +176 -145
- package/client/modeller/fasten-form.ts +85 -6
- package/client/modeller/figure-fasten-dialog.ts +42 -9
- package/client/modeller/figure-fasten-drawing.ts +233 -12
- package/client/modeller/figure-section-editor.ts +6 -6
- package/client/modeller/figure-slot-layout-panel.ts +1 -1
- package/client/modeller/gizmo.ts +12 -1
- package/client/modeller/links.ts +48 -6
- package/client/modeller/section-drawing.ts +7 -7
- package/client/modeller/size-drag.ts +112 -0
- package/client/modeller/slot-layout-host.ts +3 -3
- package/client/modeller/stage-model.ts +6 -9
- package/client/pages/figure-modeller-page.ts +1 -4
- package/dist-client/modeller/canvas.d.ts +24 -3
- package/dist-client/modeller/canvas.js +96 -11
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +7 -22
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +28 -32
- package/dist-client/modeller/editor.js +176 -153
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +36 -3
- package/dist-client/modeller/fasten-form.js +80 -5
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
- package/dist-client/modeller/figure-fasten-dialog.js +47 -10
- package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
- package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
- package/dist-client/modeller/figure-fasten-drawing.js +235 -11
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +3 -3
- package/dist-client/modeller/figure-section-editor.js +4 -4
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
- package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
- package/dist-client/modeller/gizmo.d.ts +12 -1
- package/dist-client/modeller/gizmo.js +8 -0
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/links.d.ts +14 -2
- package/dist-client/modeller/links.js +42 -4
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +3 -4
- package/dist-client/modeller/section-drawing.js +10 -9
- package/dist-client/modeller/section-drawing.js.map +1 -1
- package/dist-client/modeller/size-drag.d.ts +38 -0
- package/dist-client/modeller/size-drag.js +85 -0
- package/dist-client/modeller/size-drag.js.map +1 -0
- package/dist-client/modeller/slot-layout-host.d.ts +1 -3
- package/dist-client/modeller/slot-layout-host.js +3 -3
- package/dist-client/modeller/slot-layout-host.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +5 -7
- package/dist-client/modeller/stage-model.js +6 -6
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +0 -2
- package/dist-client/pages/figure-modeller-page.js +1 -6
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/test/align.test.ts +7 -11
- package/test/browser/acceptance-page.js +10 -0
- package/test/browser/editing.check.mjs +17 -13
- package/test/browser/links-page.js +2 -2
- package/test/browser/links.check.mjs +94 -56
- package/test/browser/modeller-page.js +30 -18
- package/test/browser/modeller-revision.check.mjs +39 -0
- package/test/browser/modeller.check.mjs +32 -10
- package/test/browser/screen-page.js +0 -2
- package/test/browser/screen.check.mjs +11 -13
- package/test/browser/section.check.mjs +2 -2
- package/test/fasten-form.test.ts +62 -1
- package/test/gizmo.test.ts +13 -15
- package/test/links.test.ts +8 -3
- package/test/resize-with-placement.test.ts +14 -13
- package/test/revision.test.ts +3 -2
- package/test/section-editor.test.ts +9 -10
- package/test/size-drag.test.ts +114 -0
- package/test/slot-layout-host.test.ts +13 -8
- package/translations/en.json +25 -22
- package/translations/es.json +25 -22
- package/translations/ja.json +25 -22
- package/translations/ko.json +25 -22
- package/translations/ms.json +25 -22
- package/translations/zh.json +25 -22
|
@@ -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, 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
|
|
@@ -39,6 +41,8 @@ export interface FastenForm {
|
|
|
39
41
|
align: Partial<Record<Axis, Alignment>>
|
|
40
42
|
alignMm: Partial<Record<Axis, number>>
|
|
41
43
|
alignInput: Partial<Record<Axis, string>>
|
|
44
|
+
/** On a point of the target's curved surface instead of a face (ADR-0140 3.9): where, as the record says it. */
|
|
45
|
+
surface?: FigureSurfacePlace
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export const freshFasten = (): FastenForm => ({
|
|
@@ -72,15 +76,38 @@ export function fastenTargetsOf(revision: FigureRevision, part: string): Array<{
|
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
79
|
+
* The point of a target's curved surface on the side the part stands on now (ADR-0140 3.9): the kernel's
|
|
80
|
+
* (`figureSurfaceKeptPlaceOf`), or, the part standing on its axis, the surface's first point.
|
|
81
|
+
*/
|
|
82
|
+
export function surfaceKeptOf(revision: FigureRevision, part: string, target: string): FigureSurfacePlace | undefined {
|
|
83
|
+
const surface = surfaceOf(revision.asset, target)
|
|
84
|
+
if (!surface) return undefined
|
|
85
|
+
return (
|
|
86
|
+
figureSurfaceKeptPlaceOf(revision.asset, part, target, { ...revision.inputs }) ??
|
|
87
|
+
(surface.form === 'around' ? { around: 0, along: 'centre' } : { toward: { x: 0, y: 1, z: 0 } })
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A new aim: the plane, another part, another face of the same part, or a point of its curved surface. Where on the
|
|
93
|
+
* face -- or the surface -- is filled in as where the part stands now, so fastening it keeps it there (`placeKeptOf`,
|
|
94
|
+
* `surfaceKeptOf`); the author changes it from there. A part aimed at a sphere, or beside a cylinder's side within its
|
|
95
|
+
* length, is aimed at the surface: it is fastened as it stands (ADR-0140 3.6).
|
|
77
96
|
*/
|
|
78
97
|
export function aimFasten(
|
|
79
98
|
revision: FigureRevision,
|
|
80
99
|
part: string,
|
|
81
100
|
form: FastenForm,
|
|
82
|
-
to: { plane: true } | { part: string } | { targetFace: FigureFace }
|
|
101
|
+
to: { plane: true } | { part: string } | { targetFace: FigureFace } | { surface: true }
|
|
83
102
|
): FastenForm {
|
|
103
|
+
if ('surface' in to) return { ...form, surface: surfaceKeptOf(revision, part, form.target), align: {}, alignMm: {}, alignInput: {} }
|
|
104
|
+
if ('part' in to) {
|
|
105
|
+
/* Beside its curved surface -- a sphere's anywhere, a cylinder's side within its length -- or with no face at all. */
|
|
106
|
+
const beside = figureSurfaceKeptPlaceOf(revision.asset, part, to.part, { ...revision.inputs })
|
|
107
|
+
const kept = beside ?? (!facesOf(revision.asset, to.part).length ? surfaceKeptOf(revision, part, to.part) : undefined)
|
|
108
|
+
if (kept) return { ...form, toPlane: false, target: to.part, surface: kept, align: {}, alignMm: {}, alignInput: {} }
|
|
109
|
+
}
|
|
110
|
+
form = { ...form, surface: undefined }
|
|
84
111
|
/* 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
112
|
const f: FastenForm =
|
|
86
113
|
'plane' in to
|
|
@@ -107,6 +134,8 @@ export function fastenChanged(
|
|
|
107
134
|
): FastenForm {
|
|
108
135
|
const f = form
|
|
109
136
|
if ('targetFace' in change) return aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace })
|
|
137
|
+
if ('onSurface' in change) return aimFasten(revision, part, f, { surface: true })
|
|
138
|
+
if ('surface' in change) return { ...f, surface: change.surface }
|
|
110
139
|
if ('mode' in change) return { ...f, facing: change.mode }
|
|
111
140
|
if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined }
|
|
112
141
|
return {
|
|
@@ -129,6 +158,8 @@ export interface FastenPlan {
|
|
|
129
158
|
action?: FigureAuthoringAction
|
|
130
159
|
/** The face the command would meet, lit on the canvas while it is tried. */
|
|
131
160
|
face?: { part: string; face: string }
|
|
161
|
+
/** On a point of the target's curved surface: which surface, about which axis, and where (ADR-0140 3.9). */
|
|
162
|
+
surface?: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
|
|
@@ -143,6 +174,19 @@ export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenFo
|
|
|
143
174
|
/* 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
175
|
const planeWay = figurePlaneFacingOf(asset)
|
|
145
176
|
const mode: FastenMode = f.toPlane ? (mountingPlaneOf(asset).facings.includes(f.facing) ? f.facing : (planeWay ?? 'meet')) : f.facing
|
|
177
|
+
const surface = !f.toPlane && f.target && f.surface ? surfaceOf(asset, f.target) : undefined
|
|
178
|
+
if (surface) {
|
|
179
|
+
/* On the surface: where is the surface's own words, so no alignment goes with it (ADR-0140 3.9). */
|
|
180
|
+
const action = {
|
|
181
|
+
kind: 'attach',
|
|
182
|
+
part,
|
|
183
|
+
to: { part: f.target, surface: f.surface },
|
|
184
|
+
facing: mode,
|
|
185
|
+
...fastenGapOf(f),
|
|
186
|
+
replace: !!heldBy(asset, part)
|
|
187
|
+
} as FigureAuthoringAction
|
|
188
|
+
return { theirFaces, targetFace, mode, alignRows: [], action, surface: { ...surface, place: f.surface! } }
|
|
189
|
+
}
|
|
146
190
|
if (!aim) return { theirFaces, targetFace, mode, alignRows: [] }
|
|
147
191
|
const align = alignOf(asset, aim, f.align, f.alignMm, f.alignInput)
|
|
148
192
|
const view = faceViewOf(revision, part, aim, mode)
|
|
@@ -177,7 +221,8 @@ export function fastenSentenceOf(
|
|
|
177
221
|
planeWord: string,
|
|
178
222
|
say: Say
|
|
179
223
|
): string {
|
|
180
|
-
const
|
|
224
|
+
const where = plan.surface ? say('figure.label.surface-' + plan.surface.form) : say('figure.label.face-' + plan.targetFace)
|
|
225
|
+
const to = form.toPlane ? planeWord : `${form.target}의 ${where}`
|
|
181
226
|
return say(`figure.text.fasten-sentence-${plan.mode}`, { to })
|
|
182
227
|
}
|
|
183
228
|
|
|
@@ -191,3 +236,37 @@ export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
|
|
|
191
236
|
: [])
|
|
192
237
|
]
|
|
193
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A part pressed on the canvas while the window is open (ADR-0140 stage 4): aimed at, on the face or the point pressed,
|
|
242
|
+
* its middle where it was pressed -- the kernel's reading of the press (`figureFastenTargetAt`), the point and normal brought into that part's own
|
|
243
|
+
* 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
|
|
244
|
+
* 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
|
|
245
|
+
* why, by its words' key.
|
|
246
|
+
*/
|
|
247
|
+
export function aimFastenAt(
|
|
248
|
+
revision: FigureRevision,
|
|
249
|
+
part: string,
|
|
250
|
+
form: FastenForm,
|
|
251
|
+
at: { id: string; point?: readonly number[]; normal?: readonly number[] }
|
|
252
|
+
): { form?: FastenForm; note?: 'aim-self' | 'aim-cannot' | 'target-would-loop' | 'aim-not-here'; part: string } {
|
|
253
|
+
const target = ownerOf(at.id)
|
|
254
|
+
const offered = fastenTargetsOf(revision, part).find(t => t.part === target)
|
|
255
|
+
if (target === part) return { note: 'aim-self', part: target }
|
|
256
|
+
if (!offered) return { note: 'aim-cannot', part: target }
|
|
257
|
+
if (offered.loops) return { note: 'target-would-loop', part: target }
|
|
258
|
+
const aimed = aimFasten(revision, part, form, { part: target })
|
|
259
|
+
const pose = (revision.geometry as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>).find(g => g.id === at.id)?.pose
|
|
260
|
+
if (!pose || !at.point || !at.normal) return { form: aimed, part: target }
|
|
261
|
+
/* Into the part's own frame: rᵀ (p − t), and the normal by rᵀ. */
|
|
262
|
+
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))
|
|
263
|
+
const lands = figureFastenTargetAt(revision.asset, target, own(at.point, pose.t), own(at.normal), { ...revision.inputs })
|
|
264
|
+
if ('face' in lands) {
|
|
265
|
+
/* On the face where it was pressed, as a point of a curved surface is: its middle there. */
|
|
266
|
+
const onFace = aimFasten(revision, part, { ...aimed, surface: undefined }, { targetFace: lands.face as FigureFace })
|
|
267
|
+
const at = lands.at as Partial<Record<Axis, number>>
|
|
268
|
+
return { form: { ...onFace, align: Object.fromEntries(Object.keys(at).map(a => [a, 'mm'])) as FastenForm['align'], alignMm: { ...at }, alignInput: {} }, part: target }
|
|
269
|
+
}
|
|
270
|
+
if ('surface' in lands) return { form: { ...aimed, toPlane: false, target, surface: lands.surface, align: {}, alignMm: {}, alignInput: {} }, part: target }
|
|
271
|
+
return { form: aimed, note: 'aim-not-here', part: target }
|
|
272
|
+
}
|
|
@@ -15,6 +15,7 @@ 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,
|
|
18
19
|
fastenChanged,
|
|
19
20
|
fastenDetailWordsOf,
|
|
20
21
|
fastenFormOf,
|
|
@@ -23,16 +24,17 @@ import {
|
|
|
23
24
|
fastenTargetsOf,
|
|
24
25
|
type FastenForm
|
|
25
26
|
} from './fasten-form.js'
|
|
26
|
-
import { fieldWordsOf, hasInstanceSize, heldBy, mountingPlaneOf, type Alignment, type Axis } from './links.js'
|
|
27
|
+
import { fieldWordsOf, hasInstanceSize, heldBy, mountingPlaneOf, surfaceOf, type Alignment, type Axis } from './links.js'
|
|
27
28
|
import type { FigureRevision } from './revision.js'
|
|
28
29
|
|
|
29
30
|
/** The target list's entry for the plane the figure stands by. */
|
|
30
31
|
const PLANE = '__plane'
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* 붙이기 창: what the selected part is fastened to, on which face, how
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* 붙이기 창: what the selected part is fastened to, on which face, how -- chosen with room to see it, in the standard
|
|
35
|
+
* popup the editor opens (`openPopup`: its title, its close, moved by its head; no backdrop, so the canvas can be
|
|
36
|
+
* pressed while it is open) (user 2026-10-17: 「속성에는 심플하고 직관적인 결과 위주로, 편집은 팝업에서 넓은 공간을
|
|
37
|
+
* 써서」; 2026-10-18: the standard popup, not a window of its own; put back 2026-10-20).
|
|
36
38
|
*
|
|
37
39
|
* Everything chosen here is tried, not done: the command it comes to is told to the editor as it changes
|
|
38
40
|
* (`preview-command`), and the canvas draws it. 「적용」 sends it once (`fasten-apply`), one step of the history;
|
|
@@ -46,11 +48,16 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
46
48
|
SidePanelStyles,
|
|
47
49
|
CommonPopupStyles,
|
|
48
50
|
css`
|
|
51
|
+
/*
|
|
52
|
+
The popup holds it at its own height and cuts what is past the screen: the window scrolls itself, within the
|
|
53
|
+
screen less the popup's head and margins, 취소 · 적용 kept at its foot (below).
|
|
54
|
+
*/
|
|
49
55
|
:host {
|
|
50
56
|
display: grid;
|
|
51
57
|
gap: 16px;
|
|
52
58
|
padding: var(--popup-padding, 16px);
|
|
53
59
|
overflow: auto;
|
|
60
|
+
max-height: calc(100dvh - 32px - 56px);
|
|
54
61
|
}
|
|
55
62
|
/* The result in one line, at the top: it changes with every choice below it. */
|
|
56
63
|
p[fasten-sentence] {
|
|
@@ -104,7 +111,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
104
111
|
margin: 0;
|
|
105
112
|
color: var(--md-sys-color-error);
|
|
106
113
|
}
|
|
107
|
-
/* 취소 · 적용 stay in sight at the window's foot when what is above them is taller than the
|
|
114
|
+
/* 취소 · 적용 stay in sight at the window's foot when what is above them is taller than the screen. */
|
|
108
115
|
ox-popup-actions {
|
|
109
116
|
position: sticky;
|
|
110
117
|
bottom: calc(-1 * var(--popup-padding, 16px));
|
|
@@ -126,6 +133,8 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
126
133
|
@property() error = ''
|
|
127
134
|
|
|
128
135
|
@state() private form?: FastenForm
|
|
136
|
+
/** Why a press on the canvas aimed at nothing, or only at the part (ADR-0140 stage 4); cleared by the next choice. */
|
|
137
|
+
@state() private aimNote = ''
|
|
129
138
|
/** The command the part's own fastening comes to, as opened: tried unchanged, nothing new is said of it. */
|
|
130
139
|
private opened?: string
|
|
131
140
|
private told = ''
|
|
@@ -195,12 +204,17 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
195
204
|
.alignMm=${f.alignMm}
|
|
196
205
|
.gap=${f.gap}
|
|
197
206
|
.partName=${this.partId}
|
|
207
|
+
.surfaceForm=${f.toPlane || !f.target ? '' : (surfaceOf(asset, f.target)?.form ?? '')}
|
|
208
|
+
.surface=${plan.surface}
|
|
198
209
|
?locked=${this.locked}
|
|
199
|
-
@fasten-change=${(e: CustomEvent<FastenChange>) =>
|
|
210
|
+
@fasten-change=${(e: CustomEvent<FastenChange>) => {
|
|
211
|
+
this.aimNote = ''
|
|
212
|
+
this.form = fastenChanged(revision, this.partId, this.form!, e.detail)
|
|
213
|
+
}}
|
|
200
214
|
></figure-fasten-drawing>`
|
|
201
215
|
return html`
|
|
202
216
|
${
|
|
203
|
-
plan.aim
|
|
217
|
+
plan.aim || plan.surface
|
|
204
218
|
? html`<p fasten-sentence role="status">
|
|
205
219
|
${fastenSentenceOf(f, plan, planeWord, (key, params) => i18next.t(key, params))}
|
|
206
220
|
</p>`
|
|
@@ -211,13 +225,15 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
211
225
|
${this.renderTargets(revision, f, planeWord)}
|
|
212
226
|
</div>
|
|
213
227
|
${
|
|
214
|
-
plan.aim
|
|
228
|
+
plan.aim || plan.surface
|
|
215
229
|
? html`<div question="face">
|
|
216
230
|
<h4>
|
|
217
231
|
${
|
|
218
232
|
/* 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
233
|
? i18next.t('figure.label.fasten-face-on-plane', { plane: planeWord })
|
|
220
|
-
:
|
|
234
|
+
: /* A sphere has no face to choose: where on its surface. */ plan.theirFaces.length
|
|
235
|
+
? i18next.t('figure.label.fasten-face-on-part', { part: f.target })
|
|
236
|
+
: i18next.t('figure.label.fasten-face-on-surface', { part: f.target })
|
|
221
237
|
}
|
|
222
238
|
</h4>
|
|
223
239
|
${drawing('face')}
|
|
@@ -252,6 +268,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
252
268
|
${/* 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
269
|
: nothing
|
|
254
270
|
}
|
|
271
|
+
${this.aimNote ? html`<p hint aim-note role="status">${this.aimNote}</p>` : nothing}
|
|
255
272
|
${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
|
|
256
273
|
<ox-popup-actions>
|
|
257
274
|
<md-text-button slot="cancel" cancel @click=${this.cancel}>${i18next.t('figure.button.cancel')}</md-text-button>
|
|
@@ -310,11 +327,25 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
310
327
|
|
|
311
328
|
private aim(part: string) {
|
|
312
329
|
if (this.locked || !this.revision || !this.form) return
|
|
330
|
+
this.aimNote = ''
|
|
313
331
|
const chosen = this.form.toPlane ? PLANE : this.form.target
|
|
314
332
|
if (part === chosen) return
|
|
315
333
|
this.form = aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part })
|
|
316
334
|
}
|
|
317
335
|
|
|
336
|
+
/**
|
|
337
|
+
* A part pressed on the canvas while the window is open (ADR-0140 stage 4): aimed at, on the face or the point pressed
|
|
338
|
+
* -- the kernel's reading of the press (`figureFastenTargetAt`), the point and normal brought into that part's own
|
|
339
|
+
* 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
|
|
340
|
+
* there is no fastening to aims at the part alone. Each is said.
|
|
341
|
+
*/
|
|
342
|
+
aimAt(at: { id: string; point?: number[]; normal?: number[] }) {
|
|
343
|
+
if (this.locked || !this.revision || !this.form) return
|
|
344
|
+
const aimed = aimFastenAt(this.revision, this.partId, this.form, at)
|
|
345
|
+
this.aimNote = aimed.note ? i18next.t(`figure.text.${aimed.note}`, { part: aimed.part, mine: this.partId }) : ''
|
|
346
|
+
if (aimed.form) this.form = aimed.form
|
|
347
|
+
}
|
|
348
|
+
|
|
318
349
|
/** The part under the pointer or the focus in the list, for the canvas to light; the plane lights nothing. */
|
|
319
350
|
private hover(part: string | null) {
|
|
320
351
|
const lit = part && part !== PLANE ? part : null
|
|
@@ -410,6 +441,8 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
410
441
|
this.setAttribute('role', 'dialog')
|
|
411
442
|
this.setAttribute('aria-label', i18next.t('figure.label.fastening'))
|
|
412
443
|
this.addEventListener('keydown', this.onKey)
|
|
444
|
+
/* 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. */
|
|
445
|
+
this.closest<HTMLElement>('ox-floating-overlay')?.style.setProperty('--overlay-center-fit-width', '640px')
|
|
413
446
|
}
|
|
414
447
|
|
|
415
448
|
private onKey = async (e: KeyboardEvent) => {
|
|
@@ -2,16 +2,23 @@ import { css, html, LitElement, nothing, svg } from 'lit'
|
|
|
2
2
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
3
|
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
|
-
import
|
|
5
|
+
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
6
|
+
import { figureAroundNormalOf, figureAroundOf, figureCaughtAngleOf, figureSurfaceAnglesOf, figureSurfacePlaceOf } from '@hatiolab/figure-model'
|
|
7
|
+
import type { FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
|
|
6
8
|
|
|
7
9
|
import { alignmentAt, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
|
|
8
10
|
import type { Alignment } from './links.js'
|
|
9
11
|
|
|
10
12
|
type Axis = 'x' | 'y' | 'z'
|
|
13
|
+
const AXES: Axis[] = ['x', 'y', 'z']
|
|
14
|
+
type SidePlace = Extract<FigureSurfacePlace, { around: number }>
|
|
15
|
+
type RoundPlace = Extract<FigureSurfacePlace, { toward: unknown }>
|
|
11
16
|
|
|
12
17
|
/** A change the drawing asks of the fastening form. */
|
|
13
18
|
export type FastenChange =
|
|
14
19
|
| { targetFace: string }
|
|
20
|
+
| { onSurface: true }
|
|
21
|
+
| { surface: FigureSurfacePlace }
|
|
15
22
|
| { mode: FastenMode }
|
|
16
23
|
| { axis: Axis; how: Alignment; mm?: number }
|
|
17
24
|
| { gap: number }
|
|
@@ -49,7 +56,8 @@ const WHERE_H = 200
|
|
|
49
56
|
*/
|
|
50
57
|
@customElement('figure-fasten-drawing')
|
|
51
58
|
export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
52
|
-
|
|
59
|
+
/* The panel's own field rows (where along a cylinder's side), as the window's other rows are. */
|
|
60
|
+
static styles = [SidePanelStyles, css`
|
|
53
61
|
:host {
|
|
54
62
|
display: grid;
|
|
55
63
|
gap: 8px;
|
|
@@ -90,6 +98,29 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
90
98
|
div[modes] button:hover:not([on]):not(:disabled) {
|
|
91
99
|
background: var(--md-sys-color-surface-container);
|
|
92
100
|
}
|
|
101
|
+
/* Faces beyond the box's six and a curved surface: tags as the hidden faces are drawn beside the box -- one look for every face. */
|
|
102
|
+
div[face-tags] {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-wrap: wrap;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
gap: 6px;
|
|
107
|
+
}
|
|
108
|
+
button[face-tag] {
|
|
109
|
+
height: 20px;
|
|
110
|
+
padding: 0 10px;
|
|
111
|
+
font: inherit;
|
|
112
|
+
font-size: var(--ui-t-small, 11.5px);
|
|
113
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
114
|
+
background: transparent;
|
|
115
|
+
border: 1px dashed var(--md-sys-color-outline);
|
|
116
|
+
border-radius: 10px;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
}
|
|
119
|
+
button[face-tag][on] {
|
|
120
|
+
color: var(--md-sys-color-on-primary-container);
|
|
121
|
+
background: var(--md-sys-color-primary-container);
|
|
122
|
+
border: 1px solid var(--md-sys-color-primary);
|
|
123
|
+
}
|
|
93
124
|
div[modes] button[on] {
|
|
94
125
|
background: var(--md-sys-color-primary-container);
|
|
95
126
|
color: var(--md-sys-color-on-primary-container);
|
|
@@ -105,8 +136,12 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
105
136
|
display: grid;
|
|
106
137
|
gap: 8px;
|
|
107
138
|
}
|
|
139
|
+
/* The place is drawn at its own size, never blown up past it: its words stay the window's size. */
|
|
108
140
|
div[where] {
|
|
109
141
|
position: relative;
|
|
142
|
+
width: 100%;
|
|
143
|
+
max-width: 360px;
|
|
144
|
+
justify-self: center;
|
|
110
145
|
}
|
|
111
146
|
div[where] input {
|
|
112
147
|
position: absolute;
|
|
@@ -119,7 +154,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
119
154
|
small[off] {
|
|
120
155
|
color: var(--md-sys-color-error);
|
|
121
156
|
}
|
|
122
|
-
`
|
|
157
|
+
`]
|
|
123
158
|
|
|
124
159
|
/** The target's name, or empty for the plane the figure stands by. */
|
|
125
160
|
@property() target = ''
|
|
@@ -140,6 +175,12 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
140
175
|
@property({ type: Boolean }) said = false
|
|
141
176
|
/** Which of its questions it draws: the face, or the way. */
|
|
142
177
|
@property() show: 'face' | 'how' = 'face'
|
|
178
|
+
/** The curved surface the target offers to be fastened to at a point (ADR-0140 3.9), if any. */
|
|
179
|
+
@property() surfaceForm: 'around' | 'toward' | '' = ''
|
|
180
|
+
/** Fastened to a point of that surface: which, about which axis, and where; undefined while on a face. */
|
|
181
|
+
@property({ attribute: false }) surface?: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }
|
|
182
|
+
/** The angles while one is dragged round the surface, told to the form when let go. */
|
|
183
|
+
@state() private turning?: { around?: number; azimuth?: number; elevation?: number }
|
|
143
184
|
|
|
144
185
|
/** Where the footprint is while dragging, and what it is caught by; undefined when nothing is dragged. */
|
|
145
186
|
@state() private dragged?: { at: Record<Axis, number>; caught: Partial<Record<Axis, string>> }
|
|
@@ -156,6 +197,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
156
197
|
|
|
157
198
|
render() {
|
|
158
199
|
if (this.show === 'how') return this.renderModes()
|
|
200
|
+
/* A part with no face (a sphere) has only its surface: its tag above, the place across the whole width. */
|
|
201
|
+
if (this.target && !this.faces.length) return html`${this.renderTarget()}<div>${this.renderWhere()}</div>`
|
|
159
202
|
return html`<div face-row>${this.target ? html`<div>${this.renderTarget()}</div>` : nothing}<div>${this.renderWhere()}</div></div>`
|
|
160
203
|
}
|
|
161
204
|
|
|
@@ -166,18 +209,18 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
166
209
|
const has = (f: string) => this.faces.includes(f)
|
|
167
210
|
/* A press is a whole change, as a drag let go is: the form applies a fastening the part already has. */
|
|
168
211
|
const pick = (f: string) => () => {
|
|
169
|
-
if (this.locked || !has(f) || f === this.face) return
|
|
212
|
+
if (this.locked || !has(f) || (!this.surface && f === this.face)) return
|
|
170
213
|
this.change({ targetFace: f })
|
|
171
214
|
this.released()
|
|
172
215
|
}
|
|
173
216
|
const seen = SEEN.map(([f, d]) => {
|
|
174
|
-
const on = this.face === f
|
|
217
|
+
const on = !this.surface && this.face === f
|
|
175
218
|
return svg`<path face=${f} d=${d} @click=${pick(f)}
|
|
176
219
|
style=${`cursor:${has(f) ? 'pointer' : 'default'};fill:${on ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-surface-container)'};fill-opacity:${has(f) ? 1 : 0.4};stroke:${on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'};stroke-width:${on ? 2 : 1}`}>
|
|
177
220
|
<title>${i18next.t('figure.label.face-' + f)}</title></path>`
|
|
178
221
|
})
|
|
179
222
|
const hidden = HIDDEN.map(([f, x, y]) => {
|
|
180
|
-
const on = this.face === f
|
|
223
|
+
const on = !this.surface && this.face === f
|
|
181
224
|
return svg`<g face=${f} @click=${pick(f)} style=${`cursor:${has(f) ? 'pointer' : 'default'};opacity:${has(f) ? 1 : 0.35}`}>
|
|
182
225
|
<rect x=${x - 26} y=${y - 10} width="52" height="20" rx="10"
|
|
183
226
|
style=${`fill:${on ? 'var(--md-sys-color-primary-container)' : 'transparent'};stroke:${on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'};stroke-dasharray:${on ? '' : '3 2'}`} />
|
|
@@ -186,14 +229,25 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
186
229
|
})
|
|
187
230
|
/* A shape's faces beyond its six outer ones -- a door frame's opening -- are tags of their own under the box. */
|
|
188
231
|
const others = this.faces.filter(f => !SEEN.some(([s]) => s === f) && !HIDDEN.some(([h]) => h === f))
|
|
189
|
-
|
|
232
|
+
/* A sphere has no face: no box of faces it has not, only its surface. */
|
|
233
|
+
return html`${this.faces.length
|
|
234
|
+
? svg`<svg viewBox="-20 0 ${PANEL_W} 126" role="img" aria-label=${this.target}>
|
|
190
235
|
${seen}
|
|
191
236
|
<text x="110" y="70" text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.target}</text>
|
|
192
237
|
${hidden}
|
|
193
|
-
</svg>`
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
238
|
+
</svg>`
|
|
239
|
+
: nothing}
|
|
240
|
+
${others.length || this.surfaceForm
|
|
241
|
+
? html`<div face-tags>
|
|
242
|
+
${others.map(f => html`<button face-tag ?on=${!this.surface && f === this.face} ?disabled=${this.locked} @click=${pick(f)}>${i18next.t('figure.label.face-' + f, { defaultValue: f })}</button>`)}
|
|
243
|
+
${this.surfaceForm
|
|
244
|
+
? html`<button face-tag surface ?on=${!!this.surface} ?disabled=${this.locked}
|
|
245
|
+
@click=${() => {
|
|
246
|
+
if (this.locked || this.surface) return
|
|
247
|
+
this.change({ onSurface: true })
|
|
248
|
+
this.released()
|
|
249
|
+
}}>${i18next.t('figure.label.surface-' + this.surfaceForm)}</button>`
|
|
250
|
+
: nothing}
|
|
197
251
|
</div>`
|
|
198
252
|
: nothing}`
|
|
199
253
|
}
|
|
@@ -266,6 +320,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
266
320
|
}
|
|
267
321
|
|
|
268
322
|
private renderWhere() {
|
|
323
|
+
if (this.surface) return this.renderSurfaceWhere(this.surface)
|
|
269
324
|
const view = this.view
|
|
270
325
|
if (!view) return nothing
|
|
271
326
|
const { h, v, sign } = screenOf(view.axes)
|
|
@@ -286,6 +341,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
286
341
|
const px = cx + sign[h] * at[h] * k - pw / 2,
|
|
287
342
|
py = cy + sign[v] * at[v] * k - ph / 2
|
|
288
343
|
const faceSized = view.face[h] !== undefined && view.face[v] !== undefined
|
|
344
|
+
/* An end disc is drawn as the disc it is: its overhang is measured on it (ADR-0140 3.9). */
|
|
345
|
+
const disc = !!view.measure.disc
|
|
289
346
|
const fx = cx - fw / 2,
|
|
290
347
|
fy = cy - fh / 2
|
|
291
348
|
const ix = Math.max(fx, px),
|
|
@@ -326,7 +383,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
326
383
|
@pointermove=${(ev: PointerEvent) => this.drag(ev, view, cx, cy)}
|
|
327
384
|
@pointerup=${() => this.drop()} @pointercancel=${() => this.drop()}>
|
|
328
385
|
${faceSized
|
|
329
|
-
? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx
|
|
386
|
+
? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx=${disc ? fw / 2 : 4} ry=${disc ? fh / 2 : 4} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`
|
|
330
387
|
: svg`<rect x="1" y="1" width=${W - 2} height=${H - 2} rx="8" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`}
|
|
331
388
|
<text face-name x=${labels.face.x} y=${labels.face.y} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
|
|
332
389
|
${guide(h)}${guide(v)}
|
|
@@ -429,4 +486,168 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
429
486
|
})
|
|
430
487
|
this.released()
|
|
431
488
|
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Where on a curved surface (ADR-0140 3.9). A cylinder's side is seen from its end: its section a circle, the point
|
|
492
|
+
* on its rim at the angle `around`, dragged round it -- caught at every 45°, where the faces it had are named -- and
|
|
493
|
+
* along its length chosen below it. A sphere is seen from above and from the side: its point dragged round the
|
|
494
|
+
* first for the direction across its axis, along the second for how high. Nothing is told the form until let go.
|
|
495
|
+
*/
|
|
496
|
+
private renderSurfaceWhere(surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }) {
|
|
497
|
+
const R = 56
|
|
498
|
+
const axis = surface.axis
|
|
499
|
+
const across = (['x', 'y', 'z'] as Axis[]).filter(a => a !== axis) as [Axis, Axis]
|
|
500
|
+
const { h, v, sign } = screenOf(across)
|
|
501
|
+
const deg = (r: number) => (r * 180) / Math.PI
|
|
502
|
+
const rad = (d: number) => (d * Math.PI) / 180
|
|
503
|
+
/* A direction across the axis on the circle drawn at (cx, cy): its point there, and the way back from a pointer. */
|
|
504
|
+
const onCircle = (n: number[], cx: number, cy: number, r = R) => ({ x: cx + sign[h] * n[AXES.indexOf(h)]! * r, y: cy + sign[v] * n[AXES.indexOf(v)]! * r })
|
|
505
|
+
const angleAt = (p: { x: number; y: number }, cx: number, cy: number) => {
|
|
506
|
+
const d = [0, 0, 0]
|
|
507
|
+
d[AXES.indexOf(h)] = sign[h] * (p.x - cx)
|
|
508
|
+
d[AXES.indexOf(v)] = sign[v] * (p.y - cy)
|
|
509
|
+
return figureAroundOf(axis, d)
|
|
510
|
+
}
|
|
511
|
+
const caught = (a: number) => figureCaughtAngleOf(a) % 360
|
|
512
|
+
/* The faces the four quarters were named by, round the circle: where a part fastened to one of them now stands. */
|
|
513
|
+
const FACE_AT: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] }
|
|
514
|
+
const ticks = (cx: number, cy: number) =>
|
|
515
|
+
[0, 90, 180, 270].map(t => {
|
|
516
|
+
const n = figureAroundNormalOf(axis, t)
|
|
517
|
+
const k = n.findIndex(c => Math.abs(c) > 0.5)
|
|
518
|
+
const word = i18next.t('figure.label.face-' + FACE_AT[AXES[k]!][n[k]! > 0 ? 1 : 0])
|
|
519
|
+
const p = onCircle(n, cx, cy, R + 16)
|
|
520
|
+
return svg`<text x=${p.x} y=${p.y + 4} text-anchor="middle" font-size="10" pointer-events="none" style="fill:var(--md-sys-color-on-surface-variant)">${word}</text>`
|
|
521
|
+
})
|
|
522
|
+
const marker = (p: { x: number; y: number }, cx: number, cy: number, label: string) => svg`
|
|
523
|
+
<line x1=${cx} y1=${cy} x2=${p.x} y2=${p.y} style="stroke:var(--md-sys-color-primary);stroke-dasharray:3 2" />
|
|
524
|
+
<circle marker cx=${p.x} cy=${p.y} r="8" style=${`fill:var(--md-sys-color-tertiary-container);stroke:var(--md-sys-color-on-surface);cursor:${this.locked ? 'default' : 'grab'}`} />
|
|
525
|
+
<text x=${cx} y=${Math.hypot(p.x - cx, p.y - cy) < 16 ? cy - 14 : cy + 4} text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${label}</text>`
|
|
526
|
+
const drag = (on: (p: { x: number; y: number }) => void) => ({
|
|
527
|
+
down: (ev: PointerEvent) => {
|
|
528
|
+
if (this.locked) return
|
|
529
|
+
;(ev.currentTarget as Element).setPointerCapture(ev.pointerId)
|
|
530
|
+
on(this.pointIn(ev))
|
|
531
|
+
},
|
|
532
|
+
move: (ev: PointerEvent) => {
|
|
533
|
+
if (!this.turning || !(ev.currentTarget as Element).hasPointerCapture?.(ev.pointerId)) return
|
|
534
|
+
on(this.pointIn(ev))
|
|
535
|
+
},
|
|
536
|
+
up: () => this.letGo(surface)
|
|
537
|
+
})
|
|
538
|
+
if (surface.form === 'around') {
|
|
539
|
+
const place = surface.place as SidePlace
|
|
540
|
+
const around = this.turning?.around ?? place.around
|
|
541
|
+
/* The section in the middle under its name, the ticks clear of both. */
|
|
542
|
+
const cx = WHERE_W / 2,
|
|
543
|
+
cy = 110
|
|
544
|
+
const d = drag(p => (this.turning = { around: caught(angleAt(p, cx, cy)) }))
|
|
545
|
+
return html`<div where surface tabindex="0" @keydown=${(ev: KeyboardEvent) => this.turnKeys(ev, surface)}>
|
|
546
|
+
${svg`<svg viewBox="0 0 ${WHERE_W} ${WHERE_H}" role="img" aria-label=${i18next.t('figure.label.surface-seen-end', { part: this.target })}
|
|
547
|
+
@pointerdown=${d.down} @pointermove=${d.move} @pointerup=${d.up} @pointercancel=${d.up}>
|
|
548
|
+
<text x=${WHERE_W / 2} y="14" text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${i18next.t('figure.label.surface-seen-end', { part: this.target })}</text>
|
|
549
|
+
<circle cx=${cx} cy=${cy} r=${R} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />
|
|
550
|
+
${ticks(cx, cy)}
|
|
551
|
+
${marker(onCircle(figureAroundNormalOf(axis, around), cx, cy), cx, cy, `${Math.round(around)}°`)}
|
|
552
|
+
</svg>`}
|
|
553
|
+
</div>
|
|
554
|
+
${this.renderAlong(surface.axis, place.along)}`
|
|
555
|
+
}
|
|
556
|
+
/* A sphere: its direction across its axis (the azimuth, by the same rule as a cylinder's around) and how high. */
|
|
557
|
+
const now = figureSurfaceAnglesOf(surface, surface.place) as { azimuth: number; elevation: number }
|
|
558
|
+
const azimuth = this.turning?.azimuth ?? now.azimuth
|
|
559
|
+
const elevation = this.turning?.elevation ?? now.elevation
|
|
560
|
+
const ax = 110,
|
|
561
|
+
ay = 110,
|
|
562
|
+
sx = 250,
|
|
563
|
+
sy = 110
|
|
564
|
+
const aboveAt = onCircle(figureAroundNormalOf(axis, azimuth), ax, ay, R * Math.cos(rad(elevation)) || 0)
|
|
565
|
+
const sideAt = { x: sx + R * Math.cos(rad(elevation)), y: sy - R * Math.sin(rad(elevation)) }
|
|
566
|
+
const d = drag(p => {
|
|
567
|
+
const above = Math.hypot(p.x - ax, p.y - ay) <= R + 16 && p.x < (ax + sx) / 2
|
|
568
|
+
this.turning = above
|
|
569
|
+
? { azimuth: caught(angleAt(p, ax, ay)), elevation }
|
|
570
|
+
: { azimuth, elevation: Math.max(-90, Math.min(90, figureCaughtAngleOf(deg(Math.atan2(sy - p.y, Math.max(0, p.x - sx)))))) }
|
|
571
|
+
})
|
|
572
|
+
return html`<div where surface tabindex="0" @keydown=${(ev: KeyboardEvent) => this.turnKeys(ev, surface)}>
|
|
573
|
+
${svg`<svg viewBox="0 0 ${WHERE_W} ${WHERE_H}" role="img" aria-label=${i18next.t('figure.label.surface-toward')}
|
|
574
|
+
@pointerdown=${d.down} @pointermove=${d.move} @pointerup=${d.up} @pointercancel=${d.up}>
|
|
575
|
+
<text x=${ax} y="14" text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${i18next.t('figure.label.surface-seen-above')}</text>
|
|
576
|
+
<circle cx=${ax} cy=${ay} r=${R} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />
|
|
577
|
+
${ticks(ax, ay)}
|
|
578
|
+
${marker(aboveAt, ax, ay, `${Math.round(azimuth)}°`)}
|
|
579
|
+
<text x=${sx + R / 2} y="14" text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${i18next.t('figure.label.surface-seen-side')}</text>
|
|
580
|
+
<path d=${`M ${sx} ${sy - R} A ${R} ${R} 0 0 1 ${sx} ${sy + R}`} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />
|
|
581
|
+
<line x1=${sx} y1=${sy - R} x2=${sx} y2=${sy + R} style="stroke:var(--md-sys-color-primary)" />
|
|
582
|
+
${marker(sideAt, sx, sy, `${Math.round(elevation)}°`)}
|
|
583
|
+
</svg>`}
|
|
584
|
+
</div>`
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Where along a cylinder's side: centred, level with an end, or a distance from the middle -- its alignment words. */
|
|
588
|
+
private renderAlong(axis: Axis, along: unknown) {
|
|
589
|
+
const how = along === 'min' || along === 'max' ? along : along && typeof along === 'object' ? 'mm' : 'centre'
|
|
590
|
+
const mm = how === 'mm' ? Number((along as { mm: number }).mm) || 0 : 0
|
|
591
|
+
const place = this.surface!.place as SidePlace
|
|
592
|
+
const tell = (next: unknown) => {
|
|
593
|
+
this.change({ surface: { around: place.around, along: next as never } })
|
|
594
|
+
this.released()
|
|
595
|
+
}
|
|
596
|
+
return html`<div form along>
|
|
597
|
+
<span>${i18next.t('figure.label.align-' + axis)}</span>
|
|
598
|
+
<select ?disabled=${this.locked} @change=${(e: Event) => {
|
|
599
|
+
const v = (e.target as HTMLSelectElement).value
|
|
600
|
+
tell(v === 'mm' ? { mm } : v)
|
|
601
|
+
}}>
|
|
602
|
+
<option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
|
|
603
|
+
<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
|
|
604
|
+
<option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>
|
|
605
|
+
<option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
|
|
606
|
+
</select>
|
|
607
|
+
${how === 'mm'
|
|
608
|
+
? html`<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(mm)}
|
|
609
|
+
?disabled=${this.locked} @change=${(e: Event) => tell({ mm: Number((e.target as HTMLInputElement).value) || 0 })} />`
|
|
610
|
+
: nothing}
|
|
611
|
+
</div>`
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** A pointer's place in the drawing's own units. */
|
|
615
|
+
private pointIn(ev: PointerEvent) {
|
|
616
|
+
const svgEl = (ev.currentTarget as Element).closest('svg') as SVGSVGElement
|
|
617
|
+
const pt = svgEl.createSVGPoint()
|
|
618
|
+
pt.x = ev.clientX
|
|
619
|
+
pt.y = ev.clientY
|
|
620
|
+
return pt.matrixTransform(svgEl.getScreenCTM()!.inverse())
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Let go: the angles dragged become the place, told to the form once. */
|
|
624
|
+
private letGo(surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }) {
|
|
625
|
+
const t = this.turning
|
|
626
|
+
if (!t) return
|
|
627
|
+
this.turning = undefined
|
|
628
|
+
this.change({ surface: this.placeOf(surface, t) })
|
|
629
|
+
this.released()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The place the angles come to: the kernel's (`figureSurfacePlaceOf`), along a side kept. */
|
|
633
|
+
private placeOf(surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }, t: { around?: number; azimuth?: number; elevation?: number }): FigureSurfacePlace {
|
|
634
|
+
const now = figureSurfaceAnglesOf(surface, surface.place) as { around?: number; azimuth?: number; elevation?: number }
|
|
635
|
+
const angles = surface.form === 'around' ? { around: t.around ?? now.around! } : { azimuth: t.azimuth ?? now.azimuth!, elevation: t.elevation ?? now.elevation! }
|
|
636
|
+
return figureSurfacePlaceOf(surface, surface.place, angles)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Arrows turn it a degree, fifteen with Shift: round the side, or round and up and down a sphere. */
|
|
640
|
+
private turnKeys(ev: KeyboardEvent, surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }) {
|
|
641
|
+
const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }[ev.key]
|
|
642
|
+
if (!step || this.locked) return
|
|
643
|
+
ev.preventDefault()
|
|
644
|
+
ev.stopPropagation()
|
|
645
|
+
const by = ev.shiftKey ? 15 : 1
|
|
646
|
+
const now = figureSurfaceAnglesOf(surface, surface.place) as { around?: number; azimuth?: number; elevation?: number }
|
|
647
|
+
if (surface.form === 'around') {
|
|
648
|
+
if (!step[0]) return
|
|
649
|
+
this.change({ surface: this.placeOf(surface, { around: now.around! + step[0] * by }) })
|
|
650
|
+
} else this.change({ surface: this.placeOf(surface, { azimuth: now.azimuth! + step[0] * by, elevation: Math.max(-90, Math.min(90, now.elevation! + step[1] * by)) }) })
|
|
651
|
+
this.released()
|
|
652
|
+
}
|
|
432
653
|
}
|