@things-factory/figure-ui 10.1.92 → 10.1.93
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/fasten-geometry.ts +152 -0
- package/client/modeller/figure-cavity-drawing.ts +259 -0
- package/client/modeller/figure-fasten-drawing.ts +406 -0
- package/client/modeller/figure-links.ts +71 -35
- package/client/modeller/figure-section-panel.ts +2 -0
- package/client/modeller/section-controls.ts +19 -0
- package/dist-client/modeller/fasten-geometry.d.ts +60 -0
- package/dist-client/modeller/fasten-geometry.js +138 -0
- package/dist-client/modeller/fasten-geometry.js.map +1 -0
- package/dist-client/modeller/figure-cavity-drawing.d.ts +48 -0
- package/dist-client/modeller/figure-cavity-drawing.js +253 -0
- package/dist-client/modeller/figure-cavity-drawing.js.map +1 -0
- package/dist-client/modeller/figure-fasten-drawing.d.ts +74 -0
- package/dist-client/modeller/figure-fasten-drawing.js +452 -0
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -0
- package/dist-client/modeller/figure-links.d.ts +7 -0
- package/dist-client/modeller/figure-links.js +70 -33
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-section-panel.d.ts +1 -0
- package/dist-client/modeller/figure-section-panel.js +2 -0
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/section-controls.d.ts +10 -0
- package/dist-client/modeller/section-controls.js +17 -0
- package/dist-client/modeller/section-controls.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/test/browser/acceptance-page.js +10 -0
- package/test/cavity-drawing.test.ts +69 -0
- package/test/fasten-drawing.test.ts +45 -0
- package/test/fasten-geometry.test.ts +79 -0
- package/test/links.test.ts +3 -1
- package/translations/en.json +11 -1
- package/translations/ja.json +11 -1
- package/translations/ko.json +11 -1
- package/translations/ms.json +11 -1
- package/translations/zh.json +11 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { css, html, LitElement, nothing, svg } from 'lit'
|
|
2
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
|
|
4
|
+
import { i18next, localize } from '@operato/i18n'
|
|
5
|
+
import type { FigureFace } from '@hatiolab/figure-model'
|
|
6
|
+
|
|
7
|
+
import { alignmentAt, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
|
|
8
|
+
import type { Alignment } from './links.js'
|
|
9
|
+
|
|
10
|
+
type Axis = 'x' | 'y' | 'z'
|
|
11
|
+
|
|
12
|
+
/** A change the drawing asks of the fastening form. */
|
|
13
|
+
export type FastenChange =
|
|
14
|
+
| { targetFace: string }
|
|
15
|
+
| { mode: FastenMode }
|
|
16
|
+
| { axis: Axis; how: Alignment; mm?: number }
|
|
17
|
+
| { gap: number }
|
|
18
|
+
|
|
19
|
+
const SEEN: Array<[FigureFace, string]> = [
|
|
20
|
+
['top', 'M60 30 L100 10 L200 10 L160 30 Z'],
|
|
21
|
+
['front', 'M60 30 L160 30 L160 100 L60 100 Z'],
|
|
22
|
+
['right', 'M160 30 L200 10 L200 80 L160 100 Z']
|
|
23
|
+
]
|
|
24
|
+
/** The faces the drawing cannot show on the box, as tags beside it where they are. */
|
|
25
|
+
const HIDDEN: Array<[FigureFace, number, number]> = [
|
|
26
|
+
['bottom', 30, 114],
|
|
27
|
+
['left', 24, 62],
|
|
28
|
+
['back', 236, 14]
|
|
29
|
+
]
|
|
30
|
+
const PANEL_W = 300
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 붙이기 그림 (user 2026-10-01) — which face of what the part is fastened to, how, and where on it; each of them chosen
|
|
35
|
+
* by pressing or dragging the drawing, none by a list of faces.
|
|
36
|
+
*
|
|
37
|
+
* The target is drawn as a box whose faces are pressed (the three it hides as tags beside it), or as the one plane the
|
|
38
|
+
* figure stands by. How is three small side views -- set on it, flush with it, its centre on it (only for a part that
|
|
39
|
+
* turns about a joint) -- and the chosen one's gap is dragged. Where is the face seen from above with the part's
|
|
40
|
+
* footprint on it: dragged, caught at the centre and at the edges from inside and outside, a guide drawn where it is
|
|
41
|
+
* caught and a dimension where it is not, which is pressed to type the number. It may go past the edges: the part that
|
|
42
|
+
* does is shaded, and a part off the face altogether is said so. Nothing is decided here: every change goes to the form
|
|
43
|
+
* (`fasten-change`), and a press or a drag let go says so (`fasten-release`), for the form to apply a fastening it holds.
|
|
44
|
+
*/
|
|
45
|
+
@customElement('figure-fasten-drawing')
|
|
46
|
+
export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
47
|
+
static styles = css`
|
|
48
|
+
:host {
|
|
49
|
+
display: grid;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
outline: none;
|
|
52
|
+
}
|
|
53
|
+
svg {
|
|
54
|
+
width: 100%;
|
|
55
|
+
display: block;
|
|
56
|
+
touch-action: none;
|
|
57
|
+
user-select: none;
|
|
58
|
+
}
|
|
59
|
+
svg text {
|
|
60
|
+
font: inherit;
|
|
61
|
+
}
|
|
62
|
+
div[modes] {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
|
65
|
+
gap: 6px;
|
|
66
|
+
}
|
|
67
|
+
div[modes] button {
|
|
68
|
+
display: grid;
|
|
69
|
+
justify-items: center;
|
|
70
|
+
padding: 2px 4px 4px;
|
|
71
|
+
font: inherit;
|
|
72
|
+
font-size: 0.72rem;
|
|
73
|
+
color: var(--md-sys-color-on-surface);
|
|
74
|
+
background: none;
|
|
75
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
76
|
+
border-radius: 8px;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
div[modes] button[on] {
|
|
80
|
+
border: 1.5px solid var(--md-sys-color-primary);
|
|
81
|
+
background: var(--md-sys-color-primary-container);
|
|
82
|
+
color: var(--md-sys-color-on-primary-container);
|
|
83
|
+
}
|
|
84
|
+
div[where] {
|
|
85
|
+
position: relative;
|
|
86
|
+
}
|
|
87
|
+
div[where] input {
|
|
88
|
+
position: absolute;
|
|
89
|
+
width: 72px;
|
|
90
|
+
font: inherit;
|
|
91
|
+
font-size: 0.75rem;
|
|
92
|
+
}
|
|
93
|
+
small[warn] {
|
|
94
|
+
color: var(--md-sys-color-tertiary);
|
|
95
|
+
}
|
|
96
|
+
small[off] {
|
|
97
|
+
color: var(--md-sys-color-error);
|
|
98
|
+
}
|
|
99
|
+
`
|
|
100
|
+
|
|
101
|
+
/** The target's name, or empty for the plane the figure stands by. */
|
|
102
|
+
@property() target = ''
|
|
103
|
+
/** The plane's name by the figure's placement: 바닥 · 천장 · 벽 · 중심. */
|
|
104
|
+
@property() planeWord = ''
|
|
105
|
+
@property({ attribute: false }) faces: string[] = []
|
|
106
|
+
@property() face = ''
|
|
107
|
+
@property({ attribute: false }) modes: FastenMode[] = ['meet', 'flush']
|
|
108
|
+
@property() mode: FastenMode = 'meet'
|
|
109
|
+
@property({ attribute: false }) view?: FaceView
|
|
110
|
+
@property({ attribute: false }) align: Partial<Record<Axis, Alignment>> = {}
|
|
111
|
+
@property({ attribute: false }) alignMm: Partial<Record<Axis, number>> = {}
|
|
112
|
+
@property({ type: Number }) gap = 0
|
|
113
|
+
/** The part being fastened (not `part`: that is every element's own). */
|
|
114
|
+
@property() partName = ''
|
|
115
|
+
@property({ type: Boolean }) locked = false
|
|
116
|
+
/** The form already says how the fastening it has lies on the face: the drawing says it only while dragging. */
|
|
117
|
+
@property({ type: Boolean }) said = false
|
|
118
|
+
|
|
119
|
+
/** Where the footprint is while dragging, and what it is caught by; undefined when nothing is dragged. */
|
|
120
|
+
@state() private dragged?: { at: Record<Axis, number>; caught: Partial<Record<Axis, string>> }
|
|
121
|
+
@state() private typing?: Axis
|
|
122
|
+
private scale = 1
|
|
123
|
+
private gapFrom?: { y: number; gap: number }
|
|
124
|
+
|
|
125
|
+
private change(detail: FastenChange) {
|
|
126
|
+
this.dispatchEvent(new CustomEvent('fasten-change', { detail, bubbles: true, composed: true }))
|
|
127
|
+
}
|
|
128
|
+
private released() {
|
|
129
|
+
this.dispatchEvent(new CustomEvent('fasten-release', { bubbles: true, composed: true }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
render() {
|
|
133
|
+
return html`${this.renderTarget()}${this.renderModes()}${this.renderWhere()}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The target box, its faces pressed; or the plane. */
|
|
137
|
+
private renderTarget() {
|
|
138
|
+
if (!this.target)
|
|
139
|
+
return svg`<svg viewBox="0 0 ${PANEL_W} 90" role="img" aria-label=${this.planeWord}>
|
|
140
|
+
<path d="M30 80 L110 30 L270 30 L190 80 Z" style="fill:var(--md-sys-color-primary);fill-opacity:.35;stroke:var(--md-sys-color-primary);stroke-width:1.5" />
|
|
141
|
+
<text x="150" y="62" text-anchor="middle" font-size="12" style="fill:var(--md-sys-color-on-primary-container)">${this.planeWord}</text>
|
|
142
|
+
</svg>`
|
|
143
|
+
const has = (f: string) => this.faces.includes(f)
|
|
144
|
+
/* A press is a whole change, as a drag let go is: the form applies a fastening the part already has. */
|
|
145
|
+
const pick = (f: string) => () => {
|
|
146
|
+
if (this.locked || !has(f) || f === this.face) return
|
|
147
|
+
this.change({ targetFace: f })
|
|
148
|
+
this.released()
|
|
149
|
+
}
|
|
150
|
+
const seen = SEEN.map(([f, d]) => {
|
|
151
|
+
const on = this.face === f
|
|
152
|
+
return svg`<path face=${f} d=${d} @click=${pick(f)}
|
|
153
|
+
style=${`cursor:${has(f) ? 'pointer' : 'default'};fill:${on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-primary-container)'};fill-opacity:${on ? 0.55 : has(f) ? 1 : 0.35};stroke:var(--md-sys-color-primary);stroke-width:1.5`}>
|
|
154
|
+
<title>${i18next.t('figure.label.face-' + f)}</title></path>`
|
|
155
|
+
})
|
|
156
|
+
const hidden = HIDDEN.map(([f, x, y]) => {
|
|
157
|
+
const on = this.face === f
|
|
158
|
+
return svg`<g face=${f} @click=${pick(f)} style=${`cursor:${has(f) ? 'pointer' : 'default'};opacity:${has(f) ? 1 : 0.35}`}>
|
|
159
|
+
<rect x=${x - 26} y=${y - 10} width="52" height="20" rx="10"
|
|
160
|
+
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'}`} />
|
|
161
|
+
<text x=${x} y=${y + 4} text-anchor="middle" font-size="11" style=${`fill:${on ? 'var(--md-sys-color-on-primary-container)' : 'var(--md-sys-color-on-surface-variant)'}`}>${i18next.t('figure.label.face-' + f)}</text>
|
|
162
|
+
</g>`
|
|
163
|
+
})
|
|
164
|
+
/* A shape's faces beyond its six outer ones -- a door frame's opening -- are tags of their own under the box. */
|
|
165
|
+
const others = this.faces.filter(f => !SEEN.some(([s]) => s === f) && !HIDDEN.some(([h]) => h === f))
|
|
166
|
+
return html`${svg`<svg viewBox="0 0 ${PANEL_W} 126" role="img" aria-label=${this.target}>
|
|
167
|
+
${seen}
|
|
168
|
+
<text x="110" y="70" text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-primary-container)">${this.target}</text>
|
|
169
|
+
${hidden}
|
|
170
|
+
</svg>`}
|
|
171
|
+
${others.length
|
|
172
|
+
? html`<div modes>
|
|
173
|
+
${others.map(f => html`<button ?on=${f === this.face} ?disabled=${this.locked} @click=${pick(f)}>${i18next.t('figure.label.face-' + f, { defaultValue: f })}</button>`)}
|
|
174
|
+
</div>`
|
|
175
|
+
: nothing}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How: three side views; the chosen one's gap is dragged. */
|
|
179
|
+
private renderModes() {
|
|
180
|
+
const icon = (m: FastenMode, on: boolean) => {
|
|
181
|
+
const g = on && m !== 'centre' ? Math.min(this.gap / 4, 10) : 0
|
|
182
|
+
const base = svg`<rect x="6" y="30" width="52" height="14" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary)" />`
|
|
183
|
+
const gapDrag = on && !this.locked ? 'ns-resize' : 'pointer'
|
|
184
|
+
if (m === 'meet')
|
|
185
|
+
return svg`<svg width="64" height="48">${base}<rect gap x="22" y=${18 - g} width="20" height="12" style=${`fill:var(--md-sys-color-surface);stroke:currentColor;cursor:${gapDrag}`} />
|
|
186
|
+
${g ? svg`<text x="46" y=${26 - g / 2} font-size="10" style="fill:currentColor">${this.gap}</text>` : nothing}</svg>`
|
|
187
|
+
if (m === 'flush')
|
|
188
|
+
return svg`<svg width="64" height="48">${base}<rect gap x="22" y=${30 - g} width="20" height="10" style=${`fill:var(--md-sys-color-surface);stroke:currentColor;cursor:${gapDrag}`} />
|
|
189
|
+
<line x1="4" y1="30" x2="60" y2="30" style="stroke:currentColor;stroke-dasharray:2 2" /></svg>`
|
|
190
|
+
return svg`<svg width="64" height="48">${base}<circle cx="32" cy="30" r="11" style="fill:var(--md-sys-color-surface);fill-opacity:.85;stroke:currentColor" /><circle cx="32" cy="30" r="2" style="fill:currentColor" /></svg>`
|
|
191
|
+
}
|
|
192
|
+
return html`<div modes>
|
|
193
|
+
${this.modes.map(m => {
|
|
194
|
+
const on = m === this.mode
|
|
195
|
+
return html`<button mode=${m} ?on=${on} ?disabled=${this.locked}
|
|
196
|
+
@click=${() => {
|
|
197
|
+
if (on) return
|
|
198
|
+
this.change({ mode: m })
|
|
199
|
+
this.released()
|
|
200
|
+
}}
|
|
201
|
+
@pointerdown=${(ev: PointerEvent) => {
|
|
202
|
+
if (!on || this.locked || !(ev.target as Element).hasAttribute?.('gap')) return
|
|
203
|
+
ev.preventDefault()
|
|
204
|
+
;(ev.currentTarget as Element).setPointerCapture(ev.pointerId)
|
|
205
|
+
this.gapFrom = { y: ev.clientY, gap: this.gap }
|
|
206
|
+
}}
|
|
207
|
+
@pointermove=${(ev: PointerEvent) => {
|
|
208
|
+
if (!this.gapFrom) return
|
|
209
|
+
this.change({ gap: Math.max(0, Math.round(this.gapFrom.gap + (this.gapFrom.y - ev.clientY) * 2)) })
|
|
210
|
+
}}
|
|
211
|
+
@pointerup=${() => {
|
|
212
|
+
if (!this.gapFrom) return
|
|
213
|
+
this.gapFrom = undefined
|
|
214
|
+
this.released()
|
|
215
|
+
}}>
|
|
216
|
+
${icon(m, on)}<span>${i18next.t('figure.label.fasten-mode-' + m)}</span>
|
|
217
|
+
</button>`
|
|
218
|
+
})}
|
|
219
|
+
</div>`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Where the part's centre is on each axis now: the drag's, or the form's. */
|
|
223
|
+
private centreOf(view: FaceView): Record<Axis, number> {
|
|
224
|
+
if (this.dragged) return this.dragged.at
|
|
225
|
+
const at = { x: 0, y: 0, z: 0 }
|
|
226
|
+
for (const a of view.axes) at[a] = offsetOf(this.align[a], this.alignMm[a], view.face[a], view.part[a])
|
|
227
|
+
return at
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What each axis is caught by now: at the centre, level with an edge, touching one from outside, or nothing. */
|
|
231
|
+
private caughtOf(view: FaceView, at: Record<Axis, number>): Partial<Record<Axis, string>> {
|
|
232
|
+
if (this.dragged) return this.dragged.caught
|
|
233
|
+
const out: Partial<Record<Axis, string>> = {}
|
|
234
|
+
for (const a of view.axes) {
|
|
235
|
+
const how = this.align[a] ?? 'centre'
|
|
236
|
+
if (how === 'centre') out[a] = 'centre'
|
|
237
|
+
else if (how === 'min' || how === 'max') out[a] = 'inside'
|
|
238
|
+
else if (view.face[a] !== undefined && Math.abs(Math.abs(at[a]) - (view.face[a]! + view.part[a]) / 2) < 0.5) out[a] = 'outside'
|
|
239
|
+
}
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private renderWhere() {
|
|
244
|
+
const view = this.view
|
|
245
|
+
if (!view) return nothing
|
|
246
|
+
const { h, v, sign } = screenOf(view.axes)
|
|
247
|
+
const W = PANEL_W,
|
|
248
|
+
H = 180
|
|
249
|
+
/* Room around the face for the part to go past its edges by about half itself; a plane with no size, three parts wide. */
|
|
250
|
+
const span = (a: Axis) => (view.face[a] !== undefined ? view.face[a]! + view.part[a] : view.part[a] * 3)
|
|
251
|
+
const k = Math.min((W - 40) / span(h), (H - 40) / span(v))
|
|
252
|
+
this.scale = k
|
|
253
|
+
const cx = W / 2,
|
|
254
|
+
cy = H / 2
|
|
255
|
+
const at = this.centreOf(view)
|
|
256
|
+
const caught = this.caughtOf(view, at)
|
|
257
|
+
const fw = (view.face[h] ?? 0) * k,
|
|
258
|
+
fh = (view.face[v] ?? 0) * k
|
|
259
|
+
const pw = view.part[h] * k,
|
|
260
|
+
ph = view.part[v] * k
|
|
261
|
+
const px = cx + sign[h] * at[h] * k - pw / 2,
|
|
262
|
+
py = cy + sign[v] * at[v] * k - ph / 2
|
|
263
|
+
const faceSized = view.face[h] !== undefined && view.face[v] !== undefined
|
|
264
|
+
const fx = cx - fw / 2,
|
|
265
|
+
fy = cy - fh / 2
|
|
266
|
+
const ix = Math.max(fx, px),
|
|
267
|
+
iy = Math.max(fy, py)
|
|
268
|
+
const iw = Math.min(fx + fw, px + pw) - ix,
|
|
269
|
+
ih = Math.min(fy + fh, py + ph) - iy
|
|
270
|
+
const { touching, sides } = faceSized ? overhangOf(view, at) : { touching: true, sides: [] as ReturnType<typeof overhangOf>['sides'] }
|
|
271
|
+
const round = this.mode === 'centre'
|
|
272
|
+
const guide = (a: Axis) => {
|
|
273
|
+
const c = caught[a]
|
|
274
|
+
if (!c) return nothing
|
|
275
|
+
const along = a === h
|
|
276
|
+
const edge = c === 'centre' ? 0 : Math.sign(at[a]) * (view.face[a] ?? 0) / 2
|
|
277
|
+
const pos = (along ? cx : cy) + sign[a] * edge * k
|
|
278
|
+
const style = `stroke:var(--md-sys-color-primary);stroke-width:${c === 'centre' ? 1 : 2.5};stroke-dasharray:${c === 'centre' ? '4 3' : ''}`
|
|
279
|
+
return along ? svg`<line x1=${pos} y1="4" x2=${pos} y2=${H - 4} style=${style} />` : svg`<line x1="4" y1=${pos} x2=${W - 4} y2=${pos} style=${style} />`
|
|
280
|
+
}
|
|
281
|
+
const dim = (a: Axis) => {
|
|
282
|
+
/* Caught by the centre or an edge, the guide says where it is; a number only where nothing caught it. */
|
|
283
|
+
if (caught[a]) return nothing
|
|
284
|
+
const along = a === h
|
|
285
|
+
const mm = Math.round(at[a])
|
|
286
|
+
const label = `${mm > 0 ? '+' : ''}${mm} mm`
|
|
287
|
+
const to = sign[a] * at[a] * k
|
|
288
|
+
const tx = along ? cx + to / 2 : 8,
|
|
289
|
+
ty = along ? H - 6 : cy + to / 2 + 4
|
|
290
|
+
return svg`${along
|
|
291
|
+
? svg`<line x1=${cx} y1=${H - 16} x2=${cx + to} y2=${H - 16} style="stroke:var(--md-sys-color-on-surface)" />`
|
|
292
|
+
: svg`<line x1="20" y1=${cy} x2="20" y2=${cy + to} style="stroke:var(--md-sys-color-on-surface)" />`}
|
|
293
|
+
<text dim=${a} x=${tx} y=${ty} font-size="11" text-anchor=${along ? 'middle' : 'start'}
|
|
294
|
+
style="fill:var(--md-sys-color-primary);cursor:text;text-decoration:underline dotted" @click=${() => !this.locked && (this.typing = a)}>${label}</text>`
|
|
295
|
+
}
|
|
296
|
+
const faceName = this.target ? `${this.target} ${i18next.t('figure.label.face-' + this.face)}` : this.planeWord
|
|
297
|
+
const sizeWords = faceSized ? ` ${Math.round(view.face[h]!)} × ${Math.round(view.face[v]!)}` : ''
|
|
298
|
+
return html`<div where tabindex="0" @keydown=${(ev: KeyboardEvent) => this.keys(ev, view)}>
|
|
299
|
+
${svg`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label=${faceName}
|
|
300
|
+
@pointermove=${(ev: PointerEvent) => this.drag(ev, view, cx, cy)}
|
|
301
|
+
@pointerup=${() => this.drop()} @pointercancel=${() => this.drop()}>
|
|
302
|
+
${faceSized
|
|
303
|
+
? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx="2" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:1.5" />`
|
|
304
|
+
: svg`<rect x="4" y="4" width=${W - 8} height=${H - 8} rx="6" style="fill:var(--md-sys-color-primary-container);fill-opacity:.5" />`}
|
|
305
|
+
<text x=${faceSized ? fx + 4 : 10} y=${faceSized ? fy + fh - 6 : 18} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
|
|
306
|
+
${guide(h)}${guide(v)}
|
|
307
|
+
<g footprint style=${`cursor:${this.locked ? 'default' : 'grab'}`} @pointerdown=${(ev: PointerEvent) => this.grab(ev, at, caught)}>
|
|
308
|
+
<rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2} style="fill:var(--md-sys-color-tertiary-container)" />
|
|
309
|
+
${faceSized && iw > 0 && ih > 0
|
|
310
|
+
? svg`<rect x=${ix} y=${iy} width=${iw} height=${ih} rx=${round ? Math.min(iw, ih) / 2 : 2} style="fill:var(--md-sys-color-surface)" />`
|
|
311
|
+
: !faceSized
|
|
312
|
+
? svg`<rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2} style="fill:var(--md-sys-color-surface)" />`
|
|
313
|
+
: nothing}
|
|
314
|
+
<rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2}
|
|
315
|
+
style=${`fill:none;stroke:var(--md-sys-color-on-surface);stroke-width:1.5;stroke-dasharray:${touching ? '' : '4 3'}`} />
|
|
316
|
+
<text x=${px + pw / 2} y=${py + ph / 2 + 4} text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.partName}</text>
|
|
317
|
+
</g>
|
|
318
|
+
${dim(h)}${dim(v)}
|
|
319
|
+
</svg>`}
|
|
320
|
+
${this.typing ? this.renderTyping(view, this.typing, at) : nothing}
|
|
321
|
+
</div>
|
|
322
|
+
${this.said && !this.dragged
|
|
323
|
+
? nothing
|
|
324
|
+
: !touching
|
|
325
|
+
? html`<small off role="status">${i18next.t('figure.text.fasten-off-face', { face: faceName })}</small>`
|
|
326
|
+
: sides.length
|
|
327
|
+
? html`<small warn role="status">${i18next.t('figure.text.fasten-past-edges', {
|
|
328
|
+
sides: sides.map(s => i18next.t(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(' · ')
|
|
329
|
+
})}</small>`
|
|
330
|
+
: nothing}`
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private renderTyping(view: FaceView, a: Axis, at: Record<Axis, number>) {
|
|
334
|
+
const along = a === screenOf(view.axes).h
|
|
335
|
+
return html`<input type="number" step="1" aria-label=${`${a.toUpperCase()} mm`} .value=${String(Math.round(at[a]))}
|
|
336
|
+
style=${along ? 'left: calc(50% - 36px); bottom: 0' : 'left: 4px; top: calc(50% - 12px)'}
|
|
337
|
+
@keydown=${(ev: KeyboardEvent) => {
|
|
338
|
+
ev.stopPropagation()
|
|
339
|
+
if (ev.key === 'Escape') this.typing = undefined
|
|
340
|
+
if (ev.key !== 'Enter') return
|
|
341
|
+
const value = Number((ev.target as HTMLInputElement).value)
|
|
342
|
+
this.typing = undefined
|
|
343
|
+
if (!Number.isFinite(value)) return
|
|
344
|
+
this.change({ axis: a, how: 'mm', mm: Math.round(value) })
|
|
345
|
+
this.released()
|
|
346
|
+
}}
|
|
347
|
+
@blur=${() => (this.typing = undefined)} />`
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private grab(ev: PointerEvent, at: Record<Axis, number>, caught: Partial<Record<Axis, string>>) {
|
|
351
|
+
if (this.locked) return
|
|
352
|
+
;(ev.currentTarget as Element).closest('svg')!.setPointerCapture(ev.pointerId)
|
|
353
|
+
this.dragged = { at: { ...at }, caught: { ...caught } }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The footprint follows the pointer, caught at the centre and edges within a few pixels. */
|
|
357
|
+
private drag(ev: PointerEvent, view: FaceView, cx: number, cy: number) {
|
|
358
|
+
if (!this.dragged) return
|
|
359
|
+
const svgEl = ev.currentTarget as SVGSVGElement
|
|
360
|
+
const pt = svgEl.createSVGPoint()
|
|
361
|
+
pt.x = ev.clientX
|
|
362
|
+
pt.y = ev.clientY
|
|
363
|
+
const p = pt.matrixTransform(svgEl.getScreenCTM()!.inverse())
|
|
364
|
+
const { h, v, sign } = screenOf(view.axes)
|
|
365
|
+
const at = { ...this.dragged.at },
|
|
366
|
+
caught: Partial<Record<Axis, string>> = {}
|
|
367
|
+
for (const [a, d] of [
|
|
368
|
+
[h, (sign[h] * (p.x - cx)) / this.scale],
|
|
369
|
+
[v, (sign[v] * (p.y - cy)) / this.scale]
|
|
370
|
+
] as const) {
|
|
371
|
+
const got = alignmentAt(d, view.face[a], view.part[a], view.edges[a], 8 / this.scale)
|
|
372
|
+
at[a] = offsetOf(got.how, got.mm, view.face[a], view.part[a])
|
|
373
|
+
if (got.caught) caught[a] = got.caught
|
|
374
|
+
}
|
|
375
|
+
this.dragged = { at, caught }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private drop() {
|
|
379
|
+
const d = this.dragged
|
|
380
|
+
if (!d || !this.view) return
|
|
381
|
+
this.dragged = undefined
|
|
382
|
+
for (const a of this.view.axes) {
|
|
383
|
+
const got = alignmentAt(d.at[a], this.view.face[a], this.view.part[a], this.view.edges[a], 0.5)
|
|
384
|
+
this.change({ axis: a, how: got.how, ...(got.how === 'mm' ? { mm: got.mm } : {}) })
|
|
385
|
+
}
|
|
386
|
+
this.released()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Arrows move the part a millimetre, ten with Shift; the centre and edges still catch it. */
|
|
390
|
+
private keys(ev: KeyboardEvent, view: FaceView) {
|
|
391
|
+
const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[ev.key]
|
|
392
|
+
if (!step || this.locked || this.typing) return
|
|
393
|
+
ev.preventDefault()
|
|
394
|
+
ev.stopPropagation()
|
|
395
|
+
const by = ev.shiftKey ? 10 : 1
|
|
396
|
+
const at = this.centreOf(view)
|
|
397
|
+
const { h, v, sign } = screenOf(view.axes)
|
|
398
|
+
;[h, v].forEach((a, i) => {
|
|
399
|
+
if (!step[i]) return
|
|
400
|
+
/* Up the screen is up the face: a Y that rises goes up with the arrow. */
|
|
401
|
+
const got = alignmentAt(at[a] + sign[a] * step[i]! * by, view.face[a], view.part[a], view.edges[a], by / 2)
|
|
402
|
+
this.change({ axis: a, how: got.how, ...(got.how === 'mm' ? { mm: got.mm } : {}) })
|
|
403
|
+
})
|
|
404
|
+
this.released()
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -50,9 +50,14 @@ import {
|
|
|
50
50
|
type MotionForm
|
|
51
51
|
} from './links.js'
|
|
52
52
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
53
|
+
import './figure-fasten-drawing.js'
|
|
54
|
+
import type { FastenChange } from './figure-fasten-drawing.js'
|
|
55
|
+
import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
|
|
53
56
|
import { HelpToggles } from './help.js'
|
|
54
57
|
|
|
55
58
|
const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
|
|
59
|
+
/** The target list's entry for the plane the figure stands by. */
|
|
60
|
+
const PLANE = '__plane'
|
|
56
61
|
|
|
57
62
|
const freshFasten = () => ({
|
|
58
63
|
face: 'bottom' as FigureFace | 'centre',
|
|
@@ -521,15 +526,19 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
521
526
|
/* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
|
|
522
527
|
/* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
|
|
523
528
|
const plane = mountingPlaneOf(asset)
|
|
529
|
+
const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'))
|
|
524
530
|
/* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
|
|
525
531
|
const mineFaces: Array<FigureFace | 'centre'> = [
|
|
526
532
|
...(facesOf(asset, this.partId).filter(face => FACES.includes(face as FigureFace) && (!f.toPlane || plane.faces.includes(face as FigureFace))) as FigureFace[]),
|
|
527
533
|
...(f.toPlane ? [] : (['centre'] as const))
|
|
528
534
|
]
|
|
529
|
-
const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0]!
|
|
530
535
|
const theirFaces = f.target ? facesOf(asset, f.target) : FACES
|
|
531
536
|
const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
|
|
532
537
|
const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
|
|
538
|
+
/* The part's own face is not chosen: the target's face and how give it (user 2026-10-01; the kernel takes no other). */
|
|
539
|
+
const mode: FastenMode = f.toPlane ? 'meet' : modeOf(f.face, f.facing)
|
|
540
|
+
const derived = f.toPlane || f.target ? myFaceFor(asset, this.partId, aim, mode) : undefined
|
|
541
|
+
const myFace = derived ?? (mineFaces.includes(f.face) ? f.face : mineFaces[0]!)
|
|
533
542
|
const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist, f.alignInput) : undefined
|
|
534
543
|
const action =
|
|
535
544
|
f.toPlane || f.target
|
|
@@ -599,42 +608,43 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
599
608
|
</div>
|
|
600
609
|
${this.renderDetachWords()}`
|
|
601
610
|
: nothing}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
:
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
</
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
611
|
+
<div form>
|
|
612
|
+
<span>${i18next.t('figure.label.target-part')}</span>
|
|
613
|
+
<select @change=${(e: Event) => {
|
|
614
|
+
const value = (e.target as HTMLSelectElement).value
|
|
615
|
+
this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value })
|
|
616
|
+
}}>
|
|
617
|
+
<option value="" ?selected=${!f.target && !f.toPlane}>${i18next.t('figure.label.target-part-placeholder')}</option>
|
|
618
|
+
<option value=${PLANE} ?selected=${f.toPlane}>${planeWord}</option>
|
|
619
|
+
${targets.map(one =>
|
|
620
|
+
loops(one)
|
|
621
|
+
? html`<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
|
|
622
|
+
: html`<option value=${one} ?selected=${one === f.target && !f.toPlane}>${one}</option>`
|
|
623
|
+
)}
|
|
624
|
+
</select>
|
|
625
|
+
</div>
|
|
626
|
+
${f.toPlane || f.target
|
|
627
|
+
? html`<p fasten-sentence>${this.sentenceOf(myFace, f.toPlane ? planeWord : `${f.target}의 ${i18next.t('figure.label.face-' + targetFace)}`, mode)}</p>
|
|
628
|
+
<figure-fasten-drawing
|
|
629
|
+
.target=${f.toPlane ? '' : f.target}
|
|
630
|
+
.planeWord=${planeWord}
|
|
631
|
+
.faces=${theirFaces}
|
|
632
|
+
.face=${targetFace}
|
|
633
|
+
.modes=${f.toPlane ? (['meet'] as FastenMode[]) : ([...(['meet', 'flush'] as FastenMode[]), ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])])}
|
|
634
|
+
.mode=${mode}
|
|
635
|
+
.view=${faceViewOf(asset, this.partId, aim)}
|
|
636
|
+
.align=${f.align}
|
|
637
|
+
.alignMm=${f.alignMm}
|
|
638
|
+
.gap=${f.gap}
|
|
639
|
+
.partName=${this.partId}
|
|
640
|
+
?locked=${this.locked}
|
|
641
|
+
?said=${!!held}
|
|
642
|
+
@fasten-change=${(e: CustomEvent<FastenChange>) => this.onFastenChange(asset, e.detail)}
|
|
643
|
+
@fasten-release=${() => this.onFastenRelease(!!held)}
|
|
644
|
+
></figure-fasten-drawing>`
|
|
645
|
+
: html`<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}
|
|
627
646
|
${(f.toPlane || f.target) ? html`<details fasten-detail>
|
|
628
647
|
<summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')}</span></summary>
|
|
629
|
-
${f.toPlane
|
|
630
|
-
? nothing
|
|
631
|
-
: html`<div form>
|
|
632
|
-
<span>${i18next.t('figure.label.facing')}</span>
|
|
633
|
-
<select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
|
|
634
|
-
<option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
|
|
635
|
-
<option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
|
|
636
|
-
</select>
|
|
637
|
-
</div>`}
|
|
638
648
|
${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
|
|
639
649
|
<div form>
|
|
640
650
|
<span>${i18next.t('figure.label.gap')}</span>
|
|
@@ -650,6 +660,32 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
650
660
|
`
|
|
651
661
|
}
|
|
652
662
|
|
|
663
|
+
/** 「lid 의 아랫면을 bin 의 윗면에 맞닿게」: what the fastening is, in one line, the part's own face said as a result. */
|
|
664
|
+
private sentenceOf(myFace: string, to: string, mode: FastenMode) {
|
|
665
|
+
return i18next.t(`figure.text.fasten-sentence-${mode}`, { part: this.partId, face: i18next.t('figure.label.face-' + myFace), to })
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** A change the drawing asks: to the form only; the preview behind follows the form. */
|
|
669
|
+
private onFastenChange(asset: FigureAsset, c: FastenChange) {
|
|
670
|
+
const f = this.fasten
|
|
671
|
+
if ('targetFace' in c) this.aimAt(asset, { ...f, targetFace: c.targetFace as FigureFace })
|
|
672
|
+
else if ('mode' in c) this.fasten = { ...f, face: c.mode === 'centre' ? 'centre' : f.face === 'centre' ? 'bottom' : f.face, facing: c.mode === 'flush' ? 'flush' : 'meet' }
|
|
673
|
+
else if ('gap' in c) this.fasten = { ...f, gap: c.gap }
|
|
674
|
+
else
|
|
675
|
+
this.fasten = {
|
|
676
|
+
...f,
|
|
677
|
+
align: { ...f.align, [c.axis]: c.how },
|
|
678
|
+
alignMm: c.how === 'mm' ? { ...f.alignMm, [c.axis]: c.mm ?? 0 } : f.alignMm
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Let go: a fastening the part already has is applied as it now is (one step); a new one waits for 붙이기. */
|
|
683
|
+
private async onFastenRelease(held: boolean) {
|
|
684
|
+
if (!held) return
|
|
685
|
+
await this.updateComplete
|
|
686
|
+
if (this.fastenCandidate) this.command(this.fastenCandidate.action)
|
|
687
|
+
}
|
|
688
|
+
|
|
653
689
|
private renderDetachWords() {
|
|
654
690
|
return this.detachWords.length ? html`<ul detach-words>${this.detachWords.map(line => html`<li>${line}</li>`)}</ul>` : nothing
|
|
655
691
|
}
|
|
@@ -6,6 +6,7 @@ import { i18next, localize } from '@operato/i18n'
|
|
|
6
6
|
import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
|
|
7
7
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
8
8
|
import { drawnOf } from './section-drawing.js'
|
|
9
|
+
import './figure-cavity-drawing.js'
|
|
9
10
|
import {
|
|
10
11
|
END_FACE,
|
|
11
12
|
SIDE_FACE,
|
|
@@ -188,6 +189,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
|
|
|
188
189
|
</small>`
|
|
189
190
|
: nothing}
|
|
190
191
|
</div>
|
|
192
|
+
${cavity ? html`<figure-cavity-drawing .body=${body} .perSide=${this.perSide} ?locked=${this.locked}></figure-cavity-drawing>` : nothing}
|
|
191
193
|
${cavity
|
|
192
194
|
? html`<div field>
|
|
193
195
|
<label>${i18next.t('figure.label.section-wall')}</label>
|
|
@@ -113,3 +113,22 @@ export function cavityDepthOf(shape: FigureEvaluatedShape): number {
|
|
|
113
113
|
const closed = (x: typeof c.start) => (x === 'open' ? 0 : x.closed)
|
|
114
114
|
return mm(e.end - e.start - closed(c.start) - closed(c.end))
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/** The inside as it stands, every side's wall written out. */
|
|
118
|
+
export function cavityWorkingOf(shape: FigureEvaluatedShape): { wall: Record<string, number>; start: FigureCavityRequest['start']; end: FigureCavityRequest['end'] } | undefined {
|
|
119
|
+
const c = shape.cavity
|
|
120
|
+
const e = shape.extrusion
|
|
121
|
+
if (!c || !e) return undefined
|
|
122
|
+
return { wall: Object.fromEntries(sidesOf(e.axis).map(s => [s, c.wall[s]!])), start: c.start, end: c.end }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A wall dragged to `value`: that side alone, or every side when the walls are one. */
|
|
126
|
+
export function wallDragged(wall: Record<string, number>, side: string, value: number, perSide: boolean): Record<string, number> {
|
|
127
|
+
const v = Math.max(1, Math.round(value))
|
|
128
|
+
return perSide ? { ...wall, [side]: v } : Object.fromEntries(Object.keys(wall).map(s => [s, v]))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** An end pressed: open becomes closed by the default wall, closed becomes open. */
|
|
132
|
+
export function endToggled(shape: FigureEvaluatedShape, now: FigureCavityRequest['start']): FigureCavityRequest['start'] {
|
|
133
|
+
return now === 'open' ? { closed: defaultWallOf(shape) } : 'open'
|
|
134
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FigureAsset, FigureFace } from '@hatiolab/figure-model';
|
|
2
|
+
import { type Alignment } from './links.js';
|
|
3
|
+
type Axis = 'x' | 'y' | 'z';
|
|
4
|
+
export type FastenMode = 'meet' | 'flush' | 'centre';
|
|
5
|
+
export type FastenTarget = {
|
|
6
|
+
part: string;
|
|
7
|
+
face: string;
|
|
8
|
+
} | 'plane';
|
|
9
|
+
/**
|
|
10
|
+
* The part's own face a fastening takes: the one looking against the target's face (올려놓기), with it (같은 면), or its
|
|
11
|
+
* centre. Read from where each face looks now, so a part standing turned offers the face that is really there.
|
|
12
|
+
* Undefined when the part has no such face.
|
|
13
|
+
*/
|
|
14
|
+
export declare function myFaceFor(asset: FigureAsset, part: string, target: FastenTarget, mode: FastenMode): FigureFace | 'centre' | undefined;
|
|
15
|
+
/** Which way the fastening is, read back from the part's face and facing. */
|
|
16
|
+
export declare const modeOf: (face: string, facing: "meet" | "flush") => FastenMode;
|
|
17
|
+
/**
|
|
18
|
+
* The face seen from above: its two axes across (as the form's alignment names them), the face's size on each -- none
|
|
19
|
+
* for a plane with no declared size -- the part's footprint on each, and whether its edges can be lined up with.
|
|
20
|
+
*/
|
|
21
|
+
export interface FaceView {
|
|
22
|
+
axes: [Axis, Axis];
|
|
23
|
+
face: Partial<Record<Axis, number>>;
|
|
24
|
+
part: Record<Axis, number>;
|
|
25
|
+
edges: Record<Axis, boolean>;
|
|
26
|
+
}
|
|
27
|
+
export declare function faceViewOf(asset: FigureAsset, part: string, target: FastenTarget): FaceView | undefined;
|
|
28
|
+
/** Where the part's centre is from the face's centre along one axis, for the way it is aligned there. */
|
|
29
|
+
export declare function offsetOf(how: Alignment | undefined, mm: number | undefined, face: number | undefined, part: number): number;
|
|
30
|
+
/**
|
|
31
|
+
* A place dropped on the face, as the form's alignment: caught at the centre, at an edge from inside (level with it:
|
|
32
|
+
* `min` / `max`, where the edges can be lined up with), or at an edge from outside (touching it, as a distance), when
|
|
33
|
+
* within `catchMm`; anywhere else a distance off centre, whole millimetres.
|
|
34
|
+
*/
|
|
35
|
+
export declare function alignmentAt(d: number, face: number | undefined, part: number, edges: boolean, catchMm: number): {
|
|
36
|
+
how: Alignment;
|
|
37
|
+
mm?: number;
|
|
38
|
+
caught?: 'centre' | 'inside' | 'outside';
|
|
39
|
+
};
|
|
40
|
+
/** How far the footprint comes past the face's edges, per side, and whether it touches the face at all. */
|
|
41
|
+
export declare function overhangOf(view: FaceView, at: Record<Axis, number>): {
|
|
42
|
+
touching: boolean;
|
|
43
|
+
sides: Array<{
|
|
44
|
+
axis: Axis;
|
|
45
|
+
side: 'low' | 'high';
|
|
46
|
+
mm: number;
|
|
47
|
+
}>;
|
|
48
|
+
};
|
|
49
|
+
/** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
|
|
50
|
+
export declare const turnsInPlace: (asset: FigureAsset, part: string) => boolean;
|
|
51
|
+
/**
|
|
52
|
+
* How the face lies on the screen: X across when it is one of the face's axes, else Z; the other down the screen -- up
|
|
53
|
+
* for Y, which rises, so a front face is seen as it stands and not upside down.
|
|
54
|
+
*/
|
|
55
|
+
export declare function screenOf(axes: readonly [Axis, Axis]): {
|
|
56
|
+
h: Axis;
|
|
57
|
+
v: Axis;
|
|
58
|
+
sign: Record<Axis, 1 | -1>;
|
|
59
|
+
};
|
|
60
|
+
export {};
|