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