@things-factory/figure-ui 10.1.35 → 10.1.37
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/graphql/v3-store.ts +30 -0
- package/client/modeller/v3-canvas.ts +145 -0
- package/client/modeller/v3-editor.ts +317 -0
- package/client/modeller/v3-product-session.ts +141 -0
- package/client/pages/figure-v3-modeller-page.ts +117 -0
- package/dist-client/graphql/v3-store.d.ts +3 -0
- package/dist-client/graphql/v3-store.js +32 -0
- package/dist-client/graphql/v3-store.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +28 -0
- package/dist-client/modeller/v3-canvas.js +157 -0
- package/dist-client/modeller/v3-canvas.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +19 -0
- package/dist-client/modeller/v3-editor.js +332 -0
- package/dist-client/modeller/v3-editor.js.map +1 -0
- package/dist-client/modeller/v3-product-session.d.ts +366 -0
- package/dist-client/modeller/v3-product-session.js +130 -0
- package/dist-client/modeller/v3-product-session.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +26 -0
- package/dist-client/pages/figure-v3-modeller-page.js +140 -0
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/docs/v3-product-cutover.md +35 -0
- package/package.json +4 -4
- package/test/v3-product-session.test.ts +133 -0
- package/test/v3-product-write.test.ts +48 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# V3 제품 전환 현황
|
|
2
|
+
|
|
3
|
+
기준일: 2026-09-21. **풀적용·배포 완료 문서가 아니다.** V3 단일 제품 경로가 목표이며 V2 병행 운영이 목표가 아니다.
|
|
4
|
+
|
|
5
|
+
## 작성한 제품 코드
|
|
6
|
+
|
|
7
|
+
- `client/modeller/v3-product-session.ts`: 모델 ID·로드·저장 경계를 V3 workspace에 연결한다. 다른 모델의 지연 응답을 버리며 로드 실패 시 이전 형상을 노출하지 않는다. UI/AI는 같은 리비전과 undo/redo를 사용한다. AI 편집은 요청 당시 context/revision을 필수로 받는다.
|
|
8
|
+
- `client/modeller/v3-canvas.ts`: 평가된 V3 형상을 Three.js에 직접 전달한다. 이전 parts/base 형식으로 변환하지 않는다. 새 문맥에서 렌더링이 실패하면 이전 모델을 지운다.
|
|
9
|
+
- `client/modeller/v3-editor.ts`: 빈 모델, 네 도형 추가, 선언된 수치 입력, 재질 토큰 변경, 실행 취소/다시 실행, 저장 연결. 관계·바인딩 편집, 이름 편집, 전체 재질 편집과 직접 조작 도구는 아직 부족하다.
|
|
10
|
+
- `client/graphql/v3-store.ts`: 정본을 변환 없이 `saveFigureV3`로 보낸다.
|
|
11
|
+
- figure-service의 `saveFigureV3`: 도메인 권한 검사, V3 검증, 정확한 이전 정본을 조건으로 하는 atomic UPDATE, 생성 요청 ID를 이용한 재시도. SQLite에서 지원하지 않는 행 잠금은 사용하지 않는다. 기존 V2 성능 점수/삼각형 수는 승계하지 않고 null로 둔다. 실제 운영 DB 저장 테스트는 아직 하지 않았다.
|
|
12
|
+
- `client/pages/figure-v3-modeller-page.ts`: 위 요소를 묶는 페이지를 작성했다. **route.ts는 여전히 기존 페이지를 import한다. 이 새 페이지는 아직 사용자 진입점이 아니다.**
|
|
13
|
+
|
|
14
|
+
## 데이터 확인
|
|
15
|
+
|
|
16
|
+
로컬 `operato-application/packages/operato-figure/db.sqlite`를 read-only로 조사했다. 삭제되지 않은 모델 32개 모두 `version: 2`다. 부품 분포는 cube114, cylinder86, polygon4, rect79, sphere5, wall2다. DB와 발행 이력은 변경하지 않았다.
|
|
17
|
+
|
|
18
|
+
V3의 네 렌더링 공급자만으로 기존 모델 전체가 보존된다고 주장할 수 없다. 구·일반 단면뿐 아니라 관절, 반복, 부품 간 배치 관계, capability, 기본 동작값을 검증해야 한다. 버전 숫자만 변경하거나 형상을 정적 메시로 평탄화하는 것은 전체 전환이 아니다.
|
|
19
|
+
|
|
20
|
+
## 활성화 전에 끝낼 항목
|
|
21
|
+
|
|
22
|
+
1. 기존 32개 자산과 발행 이력의 전환 계획·복구본·미지원 항목 보고. 형상 및 움직임을 비교 검증한다.
|
|
23
|
+
2. 새 페이지의 저작 기능 누락을 채우고 실제 브라우저에서 생성·편집·로드·저장·전환을 확인한다.
|
|
24
|
+
3. operato-figure의 AI-dock과 figure-service 도구/제안 생성기를 V3 정본 및 승인된 트랜잭션 계약으로 전환한다. 페이지 이벤트만 추가한 것은 AI 통합 완료가 아니다.
|
|
25
|
+
4. 발행 게이트, 썸네일, 카탈로그/배치 소비처도 V3 평가 결과를 사용하도록 연결한다. V2 평가 함수에 V3 정본을 전달하지 않는다.
|
|
26
|
+
5. 위 작업을 마친 뒤 기본 모델러 route를 새 페이지로 교체하고 이전 구현을 제거한다. 데이터 미전환 상태에서 route만 먼저 바꾸지 않는다.
|
|
27
|
+
|
|
28
|
+
## 검증
|
|
29
|
+
|
|
30
|
+
- figure-ui `npm test`: 241개 통과. 신규 세션/저장 검사와 기존 회귀 검사를 합친 수치다.
|
|
31
|
+
- figure-ui client 및 figure-service `tsc --noEmit`: 통과.
|
|
32
|
+
- SQLite in-memory 테스트: 테넌트 경계·이전 정본 비교·오래된 편집 거절. 실제 TypeORM 리졸버/제품 DB end-to-end 검증을 대체하지 않는다.
|
|
33
|
+
- 이번 새 V3 페이지의 실제 브라우저 검증, 기본 route 변경, 패키지 발행·배포는 하지 않았다.
|
|
34
|
+
|
|
35
|
+
배포는 사용자가 담당한다. 이 문서의 미완료 항목이 남은 채로 제품 전체 V3 전환 완료를 선언하지 않는다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-ui",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.37",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"clean": "npm run clean:server && npm run clean:client"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@hatiolab/figure-model": "^0.1.
|
|
29
|
+
"@hatiolab/figure-model": "^0.1.35",
|
|
30
30
|
"@hatiolab/things-scene": "^10.1.22",
|
|
31
31
|
"@material/web": "^2.0.0",
|
|
32
32
|
"@operato/data-grist": "^10.0.0",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"@operato/shell": "^10.0.0",
|
|
38
38
|
"@operato/styles": "^10.0.0",
|
|
39
39
|
"@operato/utils": "^10.0.0",
|
|
40
|
-
"@things-factory/figure-service": "^10.1.
|
|
40
|
+
"@things-factory/figure-service": "^10.1.37",
|
|
41
41
|
"three": "^0.185.1"
|
|
42
42
|
},
|
|
43
|
-
"gitHead": "
|
|
43
|
+
"gitHead": "d8a69e5c8803a4b9d3289df0cf38e161a207db61"
|
|
44
44
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createV3Asset, addV3Box, serializeV3Asset } from '@hatiolab/figure-model'
|
|
4
|
+
import { createV3ProductSession, type V3ProductRecord } from '../client/modeller/v3-product-session.js'
|
|
5
|
+
|
|
6
|
+
const source = (name = 'sample') =>
|
|
7
|
+
serializeV3Asset(
|
|
8
|
+
addV3Box(createV3Asset(name), {
|
|
9
|
+
id: 'body',
|
|
10
|
+
size: { x: 100, y: 100, z: 100 },
|
|
11
|
+
position: { x: 0, y: 50, z: 0 }
|
|
12
|
+
})
|
|
13
|
+
)
|
|
14
|
+
function deferred<T>() {
|
|
15
|
+
let resolve!: (value: T) => void, reject!: (error: Error) => void
|
|
16
|
+
const promise = new Promise<T>((yes, no) => {
|
|
17
|
+
resolve = yes
|
|
18
|
+
reject = no
|
|
19
|
+
})
|
|
20
|
+
return { promise, resolve, reject }
|
|
21
|
+
}
|
|
22
|
+
const store = {
|
|
23
|
+
load: async (id: string) => ({ id, source: source(id) }),
|
|
24
|
+
save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'created', source: q.source })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('existing model -> new clears geometry, ID, history and derived state', async () => {
|
|
28
|
+
const s = createV3ProductSession(store)
|
|
29
|
+
await s.open('A')
|
|
30
|
+
assert.equal(s.read().geometry.length, 1)
|
|
31
|
+
const v = s.startNew()
|
|
32
|
+
assert.equal(v.id, undefined)
|
|
33
|
+
assert.equal(v.geometry.length, 0)
|
|
34
|
+
assert.equal(v.canUndo, false)
|
|
35
|
+
assert.equal(v.dirty, false)
|
|
36
|
+
assert.equal(JSON.parse(s.source()).version, 'figure-v3-asset-1')
|
|
37
|
+
})
|
|
38
|
+
test('late load cannot overwrite another model or a new figure', async () => {
|
|
39
|
+
const pending = deferred<V3ProductRecord>()
|
|
40
|
+
const s = createV3ProductSession({ ...store, load: id => (id === 'A' ? pending.promise : store.load(id)) })
|
|
41
|
+
const first = s.open('A')
|
|
42
|
+
await s.open('B')
|
|
43
|
+
pending.resolve({ id: 'A', source: source('A') })
|
|
44
|
+
await first
|
|
45
|
+
assert.equal(s.read().id, 'B')
|
|
46
|
+
const p = deferred<V3ProductRecord>(),
|
|
47
|
+
t = createV3ProductSession({ ...store, load: () => p.promise })
|
|
48
|
+
const loading = t.open('A')
|
|
49
|
+
t.startNew()
|
|
50
|
+
p.resolve({ id: 'A', source: source() })
|
|
51
|
+
await loading
|
|
52
|
+
assert.equal(t.read().id, undefined)
|
|
53
|
+
assert.equal(t.read().geometry.length, 0)
|
|
54
|
+
})
|
|
55
|
+
test('failed load hides previous geometry and cannot save under the failed ID', async () => {
|
|
56
|
+
const s = createV3ProductSession({ ...store, load: async id => ({ id, source: id === 'bad' ? '{}' : source() }) })
|
|
57
|
+
await s.open('A')
|
|
58
|
+
await s.open('bad')
|
|
59
|
+
assert.equal(s.read().geometry.length, 0)
|
|
60
|
+
assert.equal(s.read().id, undefined)
|
|
61
|
+
assert.ok(s.read().error)
|
|
62
|
+
await assert.rejects(s.save())
|
|
63
|
+
})
|
|
64
|
+
test('UI and AI use a shared revision and history; stale/cross-model AI is rejected', async () => {
|
|
65
|
+
const s = createV3ProductSession(store)
|
|
66
|
+
await s.open('A')
|
|
67
|
+
const before = s.read(),
|
|
68
|
+
changes = [{ kind: 'set-design' as const, input: 'body.size.y', unit: 'mm' as const, value: 150 }]
|
|
69
|
+
assert.throws(() => s.apply(changes, 'ai'), /문맥/)
|
|
70
|
+
s.apply(changes, 'ai', before.revision!, before.context!)
|
|
71
|
+
assert.throws(() => s.apply(changes, 'ai', before.revision!, before.context!))
|
|
72
|
+
s.undo()
|
|
73
|
+
assert.equal(s.read().fields.find(i => i.id === 'body.size.y')?.value, 100)
|
|
74
|
+
await s.open('B')
|
|
75
|
+
assert.throws(() => s.apply(changes, 'ai', 0, before.context!))
|
|
76
|
+
})
|
|
77
|
+
test('save carries exact baseline and acknowledges only matching response', async () => {
|
|
78
|
+
let baseline: string | undefined
|
|
79
|
+
const s = createV3ProductSession({
|
|
80
|
+
...store,
|
|
81
|
+
save: async q => {
|
|
82
|
+
baseline = q.expectedSource
|
|
83
|
+
return store.save(q)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
await s.open('A')
|
|
87
|
+
const original = s.source()
|
|
88
|
+
s.apply([{ kind: 'set-design', input: 'body.size.y', unit: 'mm', value: 200 }])
|
|
89
|
+
await s.save()
|
|
90
|
+
assert.equal(baseline, original)
|
|
91
|
+
assert.equal(s.read().dirty, false)
|
|
92
|
+
s.undo()
|
|
93
|
+
assert.equal(s.read().dirty, true)
|
|
94
|
+
})
|
|
95
|
+
test('pending save locks edits/navigation and duplicate create', async () => {
|
|
96
|
+
const p = deferred<V3ProductRecord>(),
|
|
97
|
+
s = createV3ProductSession({ ...store, save: () => p.promise })
|
|
98
|
+
s.startNew()
|
|
99
|
+
const text = s.source(),
|
|
100
|
+
saving = s.save()
|
|
101
|
+
assert.throws(() => s.startNew())
|
|
102
|
+
assert.throws(() => s.undo())
|
|
103
|
+
await assert.rejects(s.save())
|
|
104
|
+
p.resolve({ id: 'new', source: text })
|
|
105
|
+
await saving
|
|
106
|
+
assert.equal(s.read().id, 'new')
|
|
107
|
+
})
|
|
108
|
+
test('save failure preserves edits and allows retry; wrong acknowledgement never clears dirty', async () => {
|
|
109
|
+
const s = createV3ProductSession({ ...store, save: async () => ({ id: 'wrong', source: source() }) })
|
|
110
|
+
await s.open('A')
|
|
111
|
+
s.apply([{ kind: 'set-design', input: 'body.size.y', unit: 'mm', value: 200 }])
|
|
112
|
+
await assert.rejects(s.save())
|
|
113
|
+
assert.equal(s.read().dirty, true)
|
|
114
|
+
assert.equal(s.read().saving, false)
|
|
115
|
+
assert.equal(s.read().id, 'A')
|
|
116
|
+
})
|
|
117
|
+
test('retry after a lost create response reuses the same creation identity', async () => {
|
|
118
|
+
const keys: string[] = []
|
|
119
|
+
const s = createV3ProductSession({
|
|
120
|
+
...store,
|
|
121
|
+
save: async q => {
|
|
122
|
+
keys.push(q.creationId!)
|
|
123
|
+
if (keys.length === 1) throw Error('response lost')
|
|
124
|
+
return { id: q.creationId!, source: q.source }
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
s.startNew()
|
|
128
|
+
await assert.rejects(s.save())
|
|
129
|
+
await s.save()
|
|
130
|
+
assert.equal(keys.length, 2)
|
|
131
|
+
assert.equal(keys[0], keys[1])
|
|
132
|
+
assert.ok(keys[0])
|
|
133
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createV3Asset, serializeV3Asset } from '@hatiolab/figure-model'
|
|
4
|
+
import { prepareV3Write, compareAndSwapV3 } from '../../figure-service/server/service/figure/figure-v3-write.js'
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
6
|
+
|
|
7
|
+
test('V3 saves preserve the graph and clear unmeasured previous scores', () => {
|
|
8
|
+
const source = serializeV3Asset(createV3Asset('빈 자산'))
|
|
9
|
+
const saved = prepareV3Write(source)
|
|
10
|
+
assert.equal(saved.source, source)
|
|
11
|
+
assert.equal(saved.name, '빈 자산')
|
|
12
|
+
assert.equal(saved.score, null)
|
|
13
|
+
assert.equal(saved.triangles, null)
|
|
14
|
+
assert.equal(saved.groups, 0)
|
|
15
|
+
assert.equal(saved.state, 'draft')
|
|
16
|
+
})
|
|
17
|
+
test('source concurrency check requires exact baseline; no implicit overwrite', () => {
|
|
18
|
+
const source = serializeV3Asset(createV3Asset('A'))
|
|
19
|
+
assert.throws(() => prepareV3Write(source, { source }))
|
|
20
|
+
assert.throws(() => prepareV3Write(source, { source }, 'changed'))
|
|
21
|
+
assert.equal(prepareV3Write(source, { source }, source).source, source)
|
|
22
|
+
})
|
|
23
|
+
test('old/invalid/duplicate-key sources never become a V3 saved draft', () => {
|
|
24
|
+
assert.throws(() => prepareV3Write('{"version":2,"parts":[]}'))
|
|
25
|
+
assert.throws(() => prepareV3Write('{"version":1,"version":2}'))
|
|
26
|
+
assert.throws(() => prepareV3Write('{'))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('SQLite compare-and-swap rejects a stale editor and another tenant', async () => {
|
|
30
|
+
const db = new DatabaseSync(':memory:')
|
|
31
|
+
try {
|
|
32
|
+
db.exec('CREATE TABLE figures(id TEXT PRIMARY KEY,domain_id TEXT,source TEXT)')
|
|
33
|
+
db.prepare('INSERT INTO figures VALUES(?,?,?)').run('A', 'tenant-A', 'original')
|
|
34
|
+
const update = async (where: { id: string; domain: { id: string }; source: string }) => {
|
|
35
|
+
const result = db
|
|
36
|
+
.prepare('UPDATE figures SET source=? WHERE id=? AND domain_id=? AND source=?')
|
|
37
|
+
.run('next', where.id, where.domain.id, where.source)
|
|
38
|
+
return { affected: Number(result.changes) }
|
|
39
|
+
}
|
|
40
|
+
await assert.rejects(compareAndSwapV3(update, 'A', 'tenant-B', 'original'))
|
|
41
|
+
await compareAndSwapV3(update, 'A', 'tenant-A', 'original')
|
|
42
|
+
await assert.rejects(compareAndSwapV3(update, 'A', 'tenant-A', 'original'))
|
|
43
|
+
await assert.rejects(compareAndSwapV3(update, 'A', 'tenant-A', undefined))
|
|
44
|
+
assert.equal(db.prepare('SELECT source FROM figures WHERE id=?').get('A')!.source, 'next')
|
|
45
|
+
} finally {
|
|
46
|
+
db.close()
|
|
47
|
+
}
|
|
48
|
+
})
|