@things-factory/figure-ui 10.1.123 → 10.1.126
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/align.ts +6 -4
- package/client/modeller/editor.ts +142 -32
- package/client/modeller/evaluated.ts +47 -0
- package/client/modeller/figure-facts.ts +8 -0
- package/client/modeller/figure-properties.ts +32 -0
- package/client/modeller/figure-verdict.ts +10 -1
- package/client/modeller/joint-fan.ts +4 -7
- package/client/modeller/links.ts +2 -1
- package/dist-client/modeller/align.d.ts +2 -1
- package/dist-client/modeller/align.js +6 -4
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +35 -1
- package/dist-client/modeller/editor.js +129 -33
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/evaluated.d.ts +12 -0
- package/dist-client/modeller/evaluated.js +42 -0
- package/dist-client/modeller/evaluated.js.map +1 -0
- package/dist-client/modeller/figure-facts.d.ts +0 -7
- package/dist-client/modeller/figure-facts.js +9 -0
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +8 -2
- package/dist-client/modeller/figure-properties.js +26 -0
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-verdict.d.ts +2 -0
- package/dist-client/modeller/figure-verdict.js +10 -2
- package/dist-client/modeller/figure-verdict.js.map +1 -1
- package/dist-client/modeller/joint-fan.js +4 -7
- package/dist-client/modeller/joint-fan.js.map +1 -1
- package/dist-client/modeller/links.js +3 -2
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +68 -112
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.scratch.tsbuildinfo +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/evaluated.test.ts +40 -0
- package/test/gizmo.test.ts +1 -1
package/client/modeller/align.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
|
|
2
|
+
import { evaluatedGeometryOf } from './evaluated.js'
|
|
2
3
|
import type { FigureAsset, FigureInputChange, FigureWorldBox } from '@hatiolab/figure-model'
|
|
3
4
|
|
|
4
5
|
import type { GizmoReach } from './gizmo.js'
|
|
@@ -19,14 +20,15 @@ export type AlignTool = 'centre-x' | 'centre-z' | 'snap'
|
|
|
19
20
|
|
|
20
21
|
const AXES = ['x', 'y', 'z'] as const
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
/** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
|
|
24
|
+
export function boxOf(asset: FigureAsset, part: string, values?: Record<string, number>): FigureWorldBox | undefined {
|
|
25
|
+
const geometry = evaluatedGeometryOf(asset, values)
|
|
24
26
|
return figureOccupiedBoxesOf(geometry, figureWritersOf(asset)).find(box => box.part === part)
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
|
|
28
30
|
export function gridStepOf(asset: FigureAsset): number {
|
|
29
|
-
const geometry =
|
|
31
|
+
const geometry = evaluatedGeometryOf(asset)
|
|
30
32
|
const boxes = figureOccupiedBoxesOf(geometry, figureWritersOf(asset))
|
|
31
33
|
if (!boxes.length) return 1
|
|
32
34
|
const span = Math.max(
|
|
@@ -6,7 +6,7 @@ import { notify } 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, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
|
|
9
|
+
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf, seedFigureEvaluation, upgradeFigurePlacement } 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'
|
|
@@ -48,6 +48,7 @@ import type { FigureRenderPart } from './renderer.js'
|
|
|
48
48
|
import type { FigureBudgetLine } from './maturity.js'
|
|
49
49
|
import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
50
50
|
import { jointFanOf } from './joint-fan.js'
|
|
51
|
+
import { seedEvaluatedGeometry } from './evaluated.js'
|
|
51
52
|
import { jointDragOf } from './joint-drag.js'
|
|
52
53
|
import type { FirstSave } from './first-save.js'
|
|
53
54
|
import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
|
|
@@ -72,6 +73,20 @@ function foldSaid(): boolean {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Product editor surface. The injected session is shared by page persistence and AI commands. */
|
|
76
|
+
/** The words a panel is given when there are none: one array, so it does not read as a change. */
|
|
77
|
+
const NO_WORDS: string[] = []
|
|
78
|
+
|
|
79
|
+
/** How long a command's candidate rests before its preview is worked out. */
|
|
80
|
+
const PREVIEW_SETTLE_MS = 150
|
|
81
|
+
|
|
82
|
+
function deepFreeze<T>(value: T): T {
|
|
83
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
84
|
+
Object.freeze(value)
|
|
85
|
+
for (const inner of Object.values(value)) deepFreeze(inner)
|
|
86
|
+
}
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
@customElement('figure-editor')
|
|
76
91
|
export class FigureEditor extends LitElement {
|
|
77
92
|
/* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
|
|
@@ -517,6 +532,8 @@ export class FigureEditor extends LitElement {
|
|
|
517
532
|
disconnectedCallback() {
|
|
518
533
|
window.removeEventListener('keydown', this.onKey, true)
|
|
519
534
|
this.widthWatch.disconnect()
|
|
535
|
+
clearTimeout(this.previewTimer)
|
|
536
|
+
clearTimeout(this.detachTimer)
|
|
520
537
|
super.disconnectedCallback()
|
|
521
538
|
}
|
|
522
539
|
/*
|
|
@@ -837,7 +854,7 @@ export class FigureEditor extends LitElement {
|
|
|
837
854
|
*/
|
|
838
855
|
private insertRecipe(recipe: FigureRecipe, values: Record<string, FigureRecipeValue>) {
|
|
839
856
|
try {
|
|
840
|
-
const asset =
|
|
857
|
+
const asset = this.plainAsset() as FigureAsset
|
|
841
858
|
const checked = parseFigureRecipe(recipe)
|
|
842
859
|
const id = figureRecipeIdFor(asset, checked)
|
|
843
860
|
const steps = resolveFigureRecipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
|
|
@@ -853,7 +870,7 @@ export class FigureEditor extends LitElement {
|
|
|
853
870
|
/** The design values of the asset being edited. */
|
|
854
871
|
private designValues(): Record<string, number> {
|
|
855
872
|
try {
|
|
856
|
-
return this.session ? (
|
|
873
|
+
return this.session ? (this.plainAsset().designInputs ?? {}) : {}
|
|
857
874
|
} catch {
|
|
858
875
|
return {}
|
|
859
876
|
}
|
|
@@ -864,7 +881,7 @@ export class FigureEditor extends LitElement {
|
|
|
864
881
|
/* A repeat is moved and sized through its row, not by a gizmo on one copy. */
|
|
865
882
|
if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
|
|
866
883
|
try {
|
|
867
|
-
const asset =
|
|
884
|
+
const asset = this.plainAsset()
|
|
868
885
|
const reach = gizmoReachOf(asset, id)
|
|
869
886
|
/*
|
|
870
887
|
A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
|
|
@@ -889,7 +906,7 @@ export class FigureEditor extends LitElement {
|
|
|
889
906
|
private transformed(id: string, result: GizmoResult) {
|
|
890
907
|
const view = this.session!.read()
|
|
891
908
|
/* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
|
|
892
|
-
const asset =
|
|
909
|
+
const asset = this.plainAsset() as FigureAsset
|
|
893
910
|
const drawn = view.geometry.find(g => g.id === id) as unknown as FigureRenderPart | undefined
|
|
894
911
|
const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
|
|
895
912
|
if (drag) {
|
|
@@ -966,7 +983,7 @@ export class FigureEditor extends LitElement {
|
|
|
966
983
|
/** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
|
|
967
984
|
private align(tool: AlignTool) {
|
|
968
985
|
this.run(() => {
|
|
969
|
-
const asset =
|
|
986
|
+
const asset = this.plainAsset()
|
|
970
987
|
const changes = alignChanges(asset, this.selected, this.reachOf(this.selected), tool)
|
|
971
988
|
if (changes.length) this.session!.apply(changes)
|
|
972
989
|
})
|
|
@@ -979,7 +996,7 @@ export class FigureEditor extends LitElement {
|
|
|
979
996
|
this.session!.edit({ kind: 'duplicate-part', target: id })
|
|
980
997
|
const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
|
|
981
998
|
if (!copy) return
|
|
982
|
-
this.session!.apply(mirrorChanges(
|
|
999
|
+
this.session!.apply(mirrorChanges(this.plainAsset(), id, copy, axis))
|
|
983
1000
|
this.selected = copy
|
|
984
1001
|
})
|
|
985
1002
|
}
|
|
@@ -1017,7 +1034,7 @@ export class FigureEditor extends LitElement {
|
|
|
1017
1034
|
@click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
|
|
1018
1035
|
}
|
|
1019
1036
|
private alignToPlane() {
|
|
1020
|
-
const asset =
|
|
1037
|
+
const asset = this.parsedAsset()
|
|
1021
1038
|
const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
|
|
1022
1039
|
if (refusal) {
|
|
1023
1040
|
this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
|
|
@@ -1031,7 +1048,7 @@ export class FigureEditor extends LitElement {
|
|
|
1031
1048
|
if (!this.selected || !reach.translate) return null
|
|
1032
1049
|
let asset: unknown
|
|
1033
1050
|
try {
|
|
1034
|
-
asset =
|
|
1051
|
+
asset = this.plainAsset()
|
|
1035
1052
|
} catch {
|
|
1036
1053
|
return null
|
|
1037
1054
|
}
|
|
@@ -1099,19 +1116,41 @@ export class FigureEditor extends LitElement {
|
|
|
1099
1116
|
}
|
|
1100
1117
|
}
|
|
1101
1118
|
/** Size response, fastening and motion for the selected part, as authoring commands. */
|
|
1102
|
-
|
|
1103
|
-
|
|
1119
|
+
/*
|
|
1120
|
+
What a panel is handed is the same object while it says the same thing: a new array each render reads as a change to
|
|
1121
|
+
the panel, which draws itself again for nothing (sixteen times for one edit).
|
|
1122
|
+
*/
|
|
1123
|
+
private dimsSaid?: { key: string; dims: [number, number, number] | undefined }
|
|
1124
|
+
private dimsOf(asset: FigureAsset): [number, number, number] | undefined {
|
|
1125
|
+
const view = this.session?.read()
|
|
1126
|
+
const key = `${view?.context}|${view?.revision}|${this.selected}`
|
|
1127
|
+
if (this.dimsSaid?.key === key) return this.dimsSaid.dims
|
|
1128
|
+
let dims: [number, number, number] | undefined
|
|
1104
1129
|
try {
|
|
1105
|
-
|
|
1130
|
+
dims = boxOf(asset, this.selected, this.currentInputs())?.dims
|
|
1106
1131
|
} catch {
|
|
1107
|
-
|
|
1132
|
+
dims = undefined
|
|
1108
1133
|
}
|
|
1109
|
-
|
|
1134
|
+
this.dimsSaid = { key, dims }
|
|
1135
|
+
return dims
|
|
1136
|
+
}
|
|
1137
|
+
private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
|
|
1138
|
+
private previewWordsNow(): string[] {
|
|
1139
|
+
const preview = this.currentPreview()
|
|
1140
|
+
const language = i18next.language
|
|
1141
|
+
if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language) return this.previewWordsSaid.words
|
|
1142
|
+
const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params))
|
|
1143
|
+
this.previewWordsSaid = { preview, part: this.selected, language, words }
|
|
1144
|
+
return words
|
|
1145
|
+
}
|
|
1146
|
+
private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
|
|
1147
|
+
let asset
|
|
1110
1148
|
try {
|
|
1111
|
-
|
|
1149
|
+
asset = this.plainAsset()
|
|
1112
1150
|
} catch {
|
|
1113
|
-
|
|
1151
|
+
return null
|
|
1114
1152
|
}
|
|
1153
|
+
const dims = this.dimsOf(asset)
|
|
1115
1154
|
return html`<figure-links
|
|
1116
1155
|
.asset=${asset}
|
|
1117
1156
|
.section=${section}
|
|
@@ -1119,10 +1158,10 @@ export class FigureEditor extends LitElement {
|
|
|
1119
1158
|
.dims=${dims}
|
|
1120
1159
|
?locked=${unavailable}
|
|
1121
1160
|
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
1122
|
-
.previewWords=${
|
|
1161
|
+
.previewWords=${this.previewWordsNow()}
|
|
1123
1162
|
.detachWords=${this.detachWords(asset as FigureAsset | undefined)}
|
|
1124
|
-
.overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) :
|
|
1125
|
-
.overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) :
|
|
1163
|
+
.overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : NO_WORDS}
|
|
1164
|
+
.overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : NO_WORDS}
|
|
1126
1165
|
.motionEnds=${section === 'motion' ? this.motionEnds() : null}
|
|
1127
1166
|
@go-fasten=${() => this.goFasten()}
|
|
1128
1167
|
@preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
|
|
@@ -1151,7 +1190,7 @@ export class FigureEditor extends LitElement {
|
|
|
1151
1190
|
private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
|
|
1152
1191
|
let asset
|
|
1153
1192
|
try {
|
|
1154
|
-
asset =
|
|
1193
|
+
asset = this.plainAsset()
|
|
1155
1194
|
} catch {
|
|
1156
1195
|
return null
|
|
1157
1196
|
}
|
|
@@ -1236,7 +1275,7 @@ export class FigureEditor extends LitElement {
|
|
|
1236
1275
|
/* A size typed in keeps the face the figure's placement keeps -- figure-model's one rule, as the gizmo and "follow the size" do. */
|
|
1237
1276
|
const part = this.selected
|
|
1238
1277
|
const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
|
|
1239
|
-
this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(
|
|
1278
|
+
this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.parsedAsset(), part, [change]) : [change]))
|
|
1240
1279
|
// Rejected edits must also restore the displayed numeric value.
|
|
1241
1280
|
input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
|
|
1242
1281
|
}
|
|
@@ -1570,7 +1609,7 @@ export class FigureEditor extends LitElement {
|
|
|
1570
1609
|
const pitch = input.valueAsNumber
|
|
1571
1610
|
if (!Number.isFinite(pitch) || pitch === field.value) return
|
|
1572
1611
|
/* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
|
|
1573
|
-
const from = this.pitchInputOf(
|
|
1612
|
+
const from = this.pitchInputOf(this.plainAsset() as FigureAsset, this.selected)
|
|
1574
1613
|
if (from) {
|
|
1575
1614
|
this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
|
|
1576
1615
|
input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
|
|
@@ -1610,19 +1649,33 @@ export class FigureEditor extends LitElement {
|
|
|
1610
1649
|
await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
|
|
1611
1650
|
}
|
|
1612
1651
|
/** The fastening form's command, seen before it is done; null clears what was shown. */
|
|
1652
|
+
/**
|
|
1653
|
+
* What a command would do is worked out on a copy of the figure -- several compiles of it -- so it waits for the field to
|
|
1654
|
+
* rest: a number typed digit by digit asks once, for the number it ends on. Clearing is at once, and the same question on
|
|
1655
|
+
* the same version is not asked again.
|
|
1656
|
+
*/
|
|
1657
|
+
private previewTimer?: ReturnType<typeof setTimeout>
|
|
1613
1658
|
private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
|
|
1659
|
+
clearTimeout(this.previewTimer)
|
|
1614
1660
|
if (!candidate || !this.session || !this.selected) {
|
|
1615
1661
|
this.preview = undefined
|
|
1616
1662
|
return
|
|
1617
1663
|
}
|
|
1618
1664
|
const view = this.session.read()
|
|
1665
|
+
const same = this.preview && this.preview.key === JSON.stringify(candidate) && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision
|
|
1666
|
+
if (same) return
|
|
1667
|
+
this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
|
|
1668
|
+
}
|
|
1669
|
+
private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
|
|
1670
|
+
if (!this.session || !this.selected) return
|
|
1671
|
+
const view = this.session.read()
|
|
1619
1672
|
const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
|
|
1620
1673
|
try {
|
|
1621
1674
|
const seen = this.session.previewCommand(candidate.action)
|
|
1622
1675
|
let face: FigureFaceArea | undefined
|
|
1623
1676
|
if (candidate.face) {
|
|
1624
1677
|
try {
|
|
1625
|
-
face = figurePartFaceOf(
|
|
1678
|
+
face = figurePartFaceOf(this.parsedAsset(), candidate.face.part, candidate.face.face, this.currentInputs())
|
|
1626
1679
|
} catch {
|
|
1627
1680
|
face = undefined
|
|
1628
1681
|
}
|
|
@@ -1660,16 +1713,35 @@ export class FigureEditor extends LitElement {
|
|
|
1660
1713
|
when the copy shows it does.
|
|
1661
1714
|
*/
|
|
1662
1715
|
private detachSaid?: { key: string; words: string[] }
|
|
1716
|
+
private detachTimer?: ReturnType<typeof setTimeout>
|
|
1717
|
+
private detachAsked?: string
|
|
1718
|
+
/**
|
|
1719
|
+
* Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
|
|
1720
|
+
* edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
|
|
1721
|
+
*/
|
|
1663
1722
|
private detachWords(asset: FigureAsset | undefined): string[] {
|
|
1664
1723
|
const view = this.session?.read()
|
|
1665
1724
|
const joint = asset?.joints?.find(j => j.body1.part === this.selected)
|
|
1666
|
-
if (!asset || !view || !joint || !this.session) return
|
|
1725
|
+
if (!asset || !view || !joint || !this.session) return NO_WORDS
|
|
1667
1726
|
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1668
1727
|
if (this.detachSaid?.key === key) return this.detachSaid.words
|
|
1728
|
+
if (this.detachAsked !== key) {
|
|
1729
|
+
this.detachAsked = key
|
|
1730
|
+
clearTimeout(this.detachTimer)
|
|
1731
|
+
this.detachTimer = setTimeout(() => {
|
|
1732
|
+
const now = this.session?.read()
|
|
1733
|
+
if (!now || `${now.context}|${now.revision}|${this.selected}` !== key) return
|
|
1734
|
+
this.computeDetachWords(asset, key)
|
|
1735
|
+
this.requestUpdate()
|
|
1736
|
+
}, PREVIEW_SETTLE_MS)
|
|
1737
|
+
}
|
|
1738
|
+
return NO_WORDS
|
|
1739
|
+
}
|
|
1740
|
+
private computeDetachWords(asset: FigureAsset, key: string): string[] {
|
|
1669
1741
|
const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
|
|
1670
1742
|
let words: string[]
|
|
1671
1743
|
try {
|
|
1672
|
-
const seen = this.session
|
|
1744
|
+
const seen = this.session!.previewCommand({ kind: 'detach', part: this.selected })
|
|
1673
1745
|
const own = seen.moves.some(m => m.body === this.selected)
|
|
1674
1746
|
words = own
|
|
1675
1747
|
? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
|
|
@@ -1695,7 +1767,7 @@ export class FigureEditor extends LitElement {
|
|
|
1695
1767
|
}
|
|
1696
1768
|
private overhangWords(asset: FigureAsset | undefined): string[] {
|
|
1697
1769
|
const view = this.session?.read()
|
|
1698
|
-
if (!asset || !view || !this.selected) return
|
|
1770
|
+
if (!asset || !view || !this.selected) return NO_WORDS
|
|
1699
1771
|
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1700
1772
|
if (this.overhangSaid?.key === key) return this.overhangSaid.words
|
|
1701
1773
|
const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
|
|
@@ -1731,7 +1803,7 @@ export class FigureEditor extends LitElement {
|
|
|
1731
1803
|
if (this.motionEndsSaid?.key !== key) {
|
|
1732
1804
|
let ends: MotionEnds | null = null
|
|
1733
1805
|
try {
|
|
1734
|
-
ends = motionEndsOf(
|
|
1806
|
+
ends = motionEndsOf(this.parsedAsset(), this.selected, this.currentInputs())
|
|
1735
1807
|
} catch {
|
|
1736
1808
|
ends = null
|
|
1737
1809
|
}
|
|
@@ -1770,9 +1842,47 @@ export class FigureEditor extends LitElement {
|
|
|
1770
1842
|
this.faceMapping = undefined
|
|
1771
1843
|
if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
|
|
1772
1844
|
}
|
|
1845
|
+
/**
|
|
1846
|
+
* The figure as the session holds it now, parsed once per text: a render asks for it a dozen times, and parsing the whole
|
|
1847
|
+
* figure is most of what a selection costs. Read, never changed -- what changes a figure goes through the session.
|
|
1848
|
+
*/
|
|
1849
|
+
/**
|
|
1850
|
+
* The session has evaluated this figure at this version, with these inputs: the screen's questions of the text it just
|
|
1851
|
+
* parsed are answered from that, not by evaluating the figure again (each is tens of milliseconds on a real figure).
|
|
1852
|
+
*/
|
|
1853
|
+
private seeded(asset: FigureAsset): FigureAsset {
|
|
1854
|
+
const view = this.session?.read()
|
|
1855
|
+
if (view?.geometry.length) {
|
|
1856
|
+
const values = this.currentInputs()
|
|
1857
|
+
seedEvaluatedGeometry(asset, values, view.geometry as never)
|
|
1858
|
+
seedFigureEvaluation(asset, values, view.geometry)
|
|
1859
|
+
}
|
|
1860
|
+
return asset
|
|
1861
|
+
}
|
|
1862
|
+
private parsed?: { text: string; asset: FigureAsset }
|
|
1863
|
+
private parsedAsset(): FigureAsset {
|
|
1864
|
+
const text = this.session!.source()
|
|
1865
|
+
if (this.parsed?.text !== text) {
|
|
1866
|
+
/* The session's text is what its workspace exported, compiled then: it is read here, not compiled again. */
|
|
1867
|
+
const plain = this.plainAsset()
|
|
1868
|
+
const upgraded = upgradeFigurePlacement(plain)
|
|
1869
|
+
this.parsed = { text, asset: upgraded === plain ? plain : this.seeded(deepFreeze(upgraded)) }
|
|
1870
|
+
}
|
|
1871
|
+
return this.parsed.asset
|
|
1872
|
+
}
|
|
1873
|
+
private plain?: { text: string; asset: FigureAsset }
|
|
1874
|
+
/**
|
|
1875
|
+
* The figure as plain data, parsed once per text and frozen: the render, the gizmo's reach and every tool read it, and
|
|
1876
|
+
* a copy for each of them cost more than the selection they serve. A change goes through the session, never through this.
|
|
1877
|
+
*/
|
|
1878
|
+
private plainAsset(): FigureAsset {
|
|
1879
|
+
const text = this.session!.source()
|
|
1880
|
+
if (this.plain?.text !== text) this.plain = { text, asset: this.seeded(deepFreeze(JSON.parse(text))) }
|
|
1881
|
+
return this.plain.asset
|
|
1882
|
+
}
|
|
1773
1883
|
private asset(): unknown {
|
|
1774
1884
|
try {
|
|
1775
|
-
return
|
|
1885
|
+
return this.plainAsset()
|
|
1776
1886
|
} catch {
|
|
1777
1887
|
return undefined
|
|
1778
1888
|
}
|
|
@@ -1907,7 +2017,7 @@ export class FigureEditor extends LitElement {
|
|
|
1907
2017
|
let summary: { kind: string; size: string } | undefined
|
|
1908
2018
|
try {
|
|
1909
2019
|
summary = this.selected
|
|
1910
|
-
? partSummaryOf(
|
|
2020
|
+
? partSummaryOf(this.parsedAsset(), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
|
|
1911
2021
|
: undefined
|
|
1912
2022
|
} catch {
|
|
1913
2023
|
summary = undefined
|
|
@@ -1944,7 +2054,7 @@ export class FigureEditor extends LitElement {
|
|
|
1944
2054
|
*/
|
|
1945
2055
|
let roomOnly: string[] = []
|
|
1946
2056
|
try {
|
|
1947
|
-
roomOnly = this.selected ? figureRoomOnlyInputsOf(
|
|
2057
|
+
roomOnly = this.selected ? figureRoomOnlyInputsOf(this.parsedAsset(), this.selected) : []
|
|
1948
2058
|
} catch {
|
|
1949
2059
|
roomOnly = []
|
|
1950
2060
|
}
|
|
@@ -1954,7 +2064,7 @@ export class FigureEditor extends LitElement {
|
|
|
1954
2064
|
try {
|
|
1955
2065
|
const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
|
|
1956
2066
|
const placed = body ? placementOf(body) : undefined
|
|
1957
|
-
const parsed = this.selected ?
|
|
2067
|
+
const parsed = this.selected ? this.parsedAsset() : undefined
|
|
1958
2068
|
if (placed && parsed)
|
|
1959
2069
|
held = {
|
|
1960
2070
|
position: placed.position,
|
|
@@ -2257,7 +2367,7 @@ export class FigureEditor extends LitElement {
|
|
|
2257
2367
|
private async askRoomRefit() {
|
|
2258
2368
|
let refit
|
|
2259
2369
|
try {
|
|
2260
|
-
refit = figureRoomRefitOf(
|
|
2370
|
+
refit = figureRoomRefitOf(this.parsedAsset())
|
|
2261
2371
|
} catch (error) {
|
|
2262
2372
|
this.error = this.said(error)
|
|
2263
2373
|
return
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
type Geometry = ReturnType<ReturnType<typeof compileFigureAsset>['evaluate']>['geometry']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A figure evaluated, held by the asset object it was asked of and the values it was asked under. One selection asks
|
|
8
|
+
* several questions of the same figure as it stands (a part's box, the grid, where a fastening keeps it, the fan of a
|
|
9
|
+
* joint), and each of them evaluated the whole figure again. Another asset object, or other values, is asked afresh.
|
|
10
|
+
* The geometry is read, never changed.
|
|
11
|
+
*/
|
|
12
|
+
const held = new WeakMap<object, Map<string, Geometry>>()
|
|
13
|
+
|
|
14
|
+
/** The values in hand that belong to a record's keys (design inputs, or state defaults). */
|
|
15
|
+
const pick = (values: Record<string, number>, of: Record<string, number>) => Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
|
|
16
|
+
|
|
17
|
+
/** The values the asset reads, in one order: the same inputs are one question however they were listed. */
|
|
18
|
+
const keyOf = (own: Record<string, number>, state: Record<string, number>) =>
|
|
19
|
+
JSON.stringify([Object.entries(own).sort(([a], [b]) => (a < b ? -1 : 1)), Object.entries(state).sort(([a], [b]) => (a < b ? -1 : 1))])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tells the questions asked of this asset the answer already in hand -- the geometry the session evaluated for this
|
|
23
|
+
* figure under these values -- so the screen does not evaluate what the workspace has just evaluated. The caller says it
|
|
24
|
+
* is the same figure: the asset is the session's text at that version, the values its inputs. Nothing is checked here.
|
|
25
|
+
*/
|
|
26
|
+
export function seedEvaluatedGeometry(asset: FigureAsset, values: Record<string, number>, geometry: Geometry): void {
|
|
27
|
+
let mine = held.get(asset)
|
|
28
|
+
if (!mine) held.set(asset, (mine = new Map()))
|
|
29
|
+
mine.set(keyOf(pick(values, asset.designInputs), pick(values, asset.stateDefaults)), geometry)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `values` left out evaluates the asset as it is; given, they replace the design inputs and state defaults they name. */
|
|
33
|
+
export function evaluatedGeometryOf(asset: FigureAsset, values?: Record<string, number>): Geometry {
|
|
34
|
+
const own = values ? pick(values, asset.designInputs) : undefined
|
|
35
|
+
const state = values ? pick(values, asset.stateDefaults) : undefined
|
|
36
|
+
const key = values ? keyOf(own!, state!) : ''
|
|
37
|
+
let mine = held.get(asset)
|
|
38
|
+
if (!mine) held.set(asset, (mine = new Map()))
|
|
39
|
+
let geometry = mine.get(key)
|
|
40
|
+
if (!geometry) {
|
|
41
|
+
if (mine.size >= 8) mine.clear()
|
|
42
|
+
const source = values ? { ...asset, designInputs: { ...asset.designInputs, ...own }, stateDefaults: { ...asset.stateDefaults, ...state } } : asset
|
|
43
|
+
geometry = compileFigureAsset(source).evaluate().geometry
|
|
44
|
+
mine.set(key, geometry)
|
|
45
|
+
}
|
|
46
|
+
return geometry
|
|
47
|
+
}
|
|
@@ -29,7 +29,15 @@ export interface FigureFigureFacts {
|
|
|
29
29
|
* records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
|
|
30
30
|
* bodies (`place@1`), in the order the model lists them.
|
|
31
31
|
*/
|
|
32
|
+
let lastFacts: { source: string; facts: FigureFigureFacts } | undefined
|
|
32
33
|
export function figureFigureFacts(source: string): FigureFigureFacts {
|
|
34
|
+
/* The panel renders more often than the figure changes; the same text says the same facts. */
|
|
35
|
+
if (lastFacts?.source === source) return lastFacts.facts
|
|
36
|
+
const facts = figureFactsOf(source)
|
|
37
|
+
lastFacts = { source, facts }
|
|
38
|
+
return facts
|
|
39
|
+
}
|
|
40
|
+
function figureFactsOf(source: string): FigureFigureFacts {
|
|
33
41
|
const asset = parseFigureAsset(source) as FigureAsset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
|
|
34
42
|
const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
|
|
35
43
|
const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
|
|
@@ -77,7 +77,39 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/*
|
|
81
|
+
The figure's own settings are shown while no part is picked; with one picked this is out of sight, and working out its
|
|
82
|
+
facts (the figure parsed and compiled) on every edit is for nobody. Out of sight it keeps what it last drew, and it
|
|
83
|
+
draws again when it comes back into view.
|
|
84
|
+
*/
|
|
85
|
+
private lastDrawn?: unknown
|
|
86
|
+
private outOfSight = false
|
|
87
|
+
private watch?: ResizeObserver
|
|
88
|
+
connectedCallback() {
|
|
89
|
+
super.connectedCallback()
|
|
90
|
+
this.watch = new ResizeObserver(() => {
|
|
91
|
+
if (this.outOfSight && this.checkVisibility?.() !== false) {
|
|
92
|
+
this.outOfSight = false
|
|
93
|
+
this.requestUpdate()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
this.watch.observe(this)
|
|
97
|
+
}
|
|
98
|
+
disconnectedCallback() {
|
|
99
|
+
this.watch?.disconnect()
|
|
100
|
+
super.disconnectedCallback()
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
render() {
|
|
104
|
+
if (this.hasUpdated && this.checkVisibility?.() === false) {
|
|
105
|
+
this.outOfSight = true
|
|
106
|
+
return this.lastDrawn ?? nothing
|
|
107
|
+
}
|
|
108
|
+
this.lastDrawn = this.draw()
|
|
109
|
+
return this.lastDrawn
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private draw() {
|
|
81
113
|
const facts = this.facts()
|
|
82
114
|
if (!facts) return nothing
|
|
83
115
|
/* The figure's type is the page's (it lives on the row); it is slotted into the first section. */
|
|
@@ -17,6 +17,8 @@ import type { FigureBudgetUse, FigureMaturityAxis, FigureReport } from './maturi
|
|
|
17
17
|
|
|
18
18
|
/** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
|
|
19
19
|
export const VERDICT_SETTLE_MS = 400
|
|
20
|
+
/** How long the figure rests before its report is measured again. */
|
|
21
|
+
const MEASURE_SETTLE_MS = 200
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* 성숙도 — the modeller's verdict while editing.
|
|
@@ -399,11 +401,18 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
|
|
|
399
401
|
|
|
400
402
|
disconnectedCallback() {
|
|
401
403
|
clearTimeout(this.timer)
|
|
404
|
+
clearTimeout(this.measureTimer)
|
|
402
405
|
super.disconnectedCallback()
|
|
403
406
|
}
|
|
404
407
|
|
|
408
|
+
/** The report is measured once the figure has settled: it is read beside the work, and each measure compiles the figure several times. */
|
|
409
|
+
private measureTimer?: ReturnType<typeof setTimeout>
|
|
405
410
|
updated(changes: Map<string, unknown>) {
|
|
406
|
-
if (changes.has('source'))
|
|
411
|
+
if (changes.has('source')) {
|
|
412
|
+
clearTimeout(this.measureTimer)
|
|
413
|
+
if (this.report === undefined && !this.measureTimer) this.measure()
|
|
414
|
+
else this.measureTimer = setTimeout(() => this.measure(), MEASURE_SETTLE_MS)
|
|
415
|
+
}
|
|
407
416
|
if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
|
|
408
417
|
}
|
|
409
418
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compileFigureGraph, figureWorldBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
import type { FigureRenderPart } from './renderer.js'
|
|
5
5
|
import { sectionShape } from './drawn-shapes.js'
|
|
6
6
|
import { jointAxisUnit } from './joint-axis.js'
|
|
7
|
+
import { evaluatedGeometryOf } from './evaluated.js'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
* Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
|
|
@@ -204,9 +205,7 @@ export function jointFanOf(asset: FigureAsset, part: string, values: Record<stri
|
|
|
204
205
|
let box: WorldBox | undefined
|
|
205
206
|
try {
|
|
206
207
|
box = figureWorldBoxesOf(
|
|
207
|
-
|
|
208
|
-
.evaluate()
|
|
209
|
-
.geometry.filter(g => g.id === part),
|
|
208
|
+
evaluatedGeometryOf(asset, values).filter(g => g.id === part),
|
|
210
209
|
figureWritersOf(asset)
|
|
211
210
|
)[0]
|
|
212
211
|
} catch {
|
|
@@ -274,9 +273,7 @@ function pick(values: Record<string, number>, of: Record<string, number>): Recor
|
|
|
274
273
|
/** A part's largest own measure (a box side, a diameter, a length) at the inputs given; 0 when it cannot be read. */
|
|
275
274
|
function largestMeasureOf(asset: FigureAsset, part: string, inputs: Record<string, number>): number {
|
|
276
275
|
try {
|
|
277
|
-
const body =
|
|
278
|
-
.evaluate()
|
|
279
|
-
.geometry.find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
|
|
276
|
+
const body = evaluatedGeometryOf(asset, inputs).find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
|
|
280
277
|
const dims = Object.entries(body?.shape?.dimensions ?? {}).map(([k, v]) => (k.startsWith('radius') ? v * 2 : v))
|
|
281
278
|
return dims.length ? Math.max(...dims) : 0
|
|
282
279
|
} catch {
|
package/client/modeller/links.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset
|
|
|
2
2
|
import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
import { startingShapeKindOf } from './starting-shape.js'
|
|
5
|
+
import { evaluatedGeometryOf } from './evaluated.js'
|
|
5
6
|
import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
|
|
6
7
|
|
|
7
8
|
/*
|
|
@@ -394,7 +395,7 @@ export function placeKeptOf(
|
|
|
394
395
|
const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>>, alignInput: {} as Partial<Record<Axis, string>> }
|
|
395
396
|
let geometry
|
|
396
397
|
try {
|
|
397
|
-
geometry =
|
|
398
|
+
geometry = evaluatedGeometryOf(asset) as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
|
|
398
399
|
} catch {
|
|
399
400
|
return kept
|
|
400
401
|
}
|
|
@@ -12,7 +12,8 @@ import type { GizmoReach } from './gizmo.js';
|
|
|
12
12
|
* where the gizmo could (`GizmoReach`).
|
|
13
13
|
*/
|
|
14
14
|
export type AlignTool = 'centre-x' | 'centre-z' | 'snap';
|
|
15
|
-
|
|
15
|
+
/** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
|
|
16
|
+
export declare function boxOf(asset: FigureAsset, part: string, values?: Record<string, number>): FigureWorldBox | undefined;
|
|
16
17
|
/** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
|
|
17
18
|
export declare function gridStepOf(asset: FigureAsset): number;
|
|
18
19
|
export declare function alignChanges(asset: FigureAsset, part: string, reach: GizmoReach, tool: AlignTool): FigureInputChange[];
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model';
|
|
2
|
+
import { evaluatedGeometryOf } from './evaluated.js';
|
|
2
3
|
const AXES = ['x', 'y', 'z'];
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
/** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
|
|
5
|
+
export function boxOf(asset, part, values) {
|
|
6
|
+
const geometry = evaluatedGeometryOf(asset, values);
|
|
5
7
|
return figureOccupiedBoxesOf(geometry, figureWritersOf(asset)).find(box => box.part === part);
|
|
6
8
|
}
|
|
7
9
|
/** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
|
|
8
10
|
export function gridStepOf(asset) {
|
|
9
|
-
const geometry =
|
|
11
|
+
const geometry = evaluatedGeometryOf(asset);
|
|
10
12
|
const boxes = figureOccupiedBoxesOf(geometry, figureWritersOf(asset));
|
|
11
13
|
if (!boxes.length)
|
|
12
14
|
return 1;
|