@things-factory/figure-ui 10.1.39 → 10.1.42
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/index.ts +1 -0
- package/client/modeller/figure-v3-material.ts +248 -0
- package/client/modeller/figure-v3-parts.ts +16 -1
- package/client/modeller/figure-v3-properties.ts +254 -0
- package/client/modeller/modeller-route.ts +25 -0
- package/client/modeller/v3-align.ts +84 -0
- package/client/modeller/v3-canvas.ts +167 -5
- package/client/modeller/v3-editor.ts +207 -47
- package/client/modeller/v3-figure-facts.ts +53 -0
- package/client/modeller/v3-gizmo.ts +80 -0
- package/client/modeller/v3-occupancy.ts +6 -2
- package/client/pages/figure-list-page.ts +5 -3
- package/client/pages/figure-modeller-page.ts +9 -0
- package/client/pages/figure-v3-modeller-page.ts +68 -13
- package/client/types.ts +2 -0
- package/dist-client/graphql/index.js +1 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +36 -0
- package/dist-client/modeller/figure-v3-material.js +249 -0
- package/dist-client/modeller/figure-v3-material.js.map +1 -0
- package/dist-client/modeller/figure-v3-parts.js +16 -1
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
- package/dist-client/modeller/figure-v3-properties.js +259 -0
- package/dist-client/modeller/figure-v3-properties.js.map +1 -0
- package/dist-client/modeller/modeller-route.d.ts +16 -0
- package/dist-client/modeller/modeller-route.js +27 -0
- package/dist-client/modeller/modeller-route.js.map +1 -0
- package/dist-client/modeller/v3-align.d.ts +26 -0
- package/dist-client/modeller/v3-align.js +65 -0
- package/dist-client/modeller/v3-align.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +24 -0
- package/dist-client/modeller/v3-canvas.js +181 -5
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +24 -0
- package/dist-client/modeller/v3-editor.js +218 -44
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
- package/dist-client/modeller/v3-figure-facts.js +34 -0
- package/dist-client/modeller/v3-figure-facts.js.map +1 -0
- package/dist-client/modeller/v3-gizmo.d.ts +48 -0
- package/dist-client/modeller/v3-gizmo.js +40 -0
- package/dist-client/modeller/v3-gizmo.js.map +1 -0
- package/dist-client/modeller/v3-occupancy.d.ts +5 -1
- package/dist-client/modeller/v3-occupancy.js +4 -2
- package/dist-client/modeller/v3-occupancy.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +1 -0
- package/dist-client/pages/figure-list-page.js +5 -3
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.js +9 -0
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +16 -2
- package/dist-client/pages/figure-v3-modeller-page.js +78 -22
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/types.d.ts +2 -0
- package/dist-client/types.js.map +1 -1
- package/package.json +4 -4
- package/test/modeller-route.test.ts +42 -0
- package/test/v3-align.test.ts +64 -0
- package/test/v3-gizmo.test.ts +97 -0
- package/test/v3-material.test.ts +68 -0
- package/test/v3-parts-wiring.test.ts +56 -0
- package/test/v3-properties.test.ts +93 -0
- package/test/v3-theme.test.ts +67 -0
- package/translations/en.json +11 -0
- package/translations/ja.json +11 -0
- package/translations/ko.json +11 -0
- package/translations/ms.json +11 -0
- package/translations/zh.json +11 -0
package/dist-client/types.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface Figure {
|
|
|
29
29
|
id: string;
|
|
30
30
|
name: string;
|
|
31
31
|
};
|
|
32
|
+
/** Which format the stored source is in — `'v2'`, `'v3'`, or null when the server cannot tell. */
|
|
33
|
+
format?: 'v2' | 'v3' | null;
|
|
32
34
|
/** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */
|
|
33
35
|
source?: string;
|
|
34
36
|
properties?: string;
|
package/dist-client/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 저작 보조가 낸 후보.\n *\n * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\n/** A server-remeasured set of draft-only changes that clears every blocking sizing split. */\nexport interface FigureGateFixPlan {\n fixes: FigureGateFix[]\n resolved: number\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n fixPlan?: FigureGateFixPlan\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** Which format the stored source is in — `'v2'`, `'v3'`, or null when the server cannot tell. */\n format?: 'v2' | 'v3' | null\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 저작 보조가 낸 후보.\n *\n * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\n/** A server-remeasured set of draft-only changes that clears every blocking sizing split. */\nexport interface FigureGateFixPlan {\n fixes: FigureGateFix[]\n resolved: number\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n fixPlan?: FigureGateFixPlan\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
|
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.42",
|
|
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.42",
|
|
30
30
|
"@hatiolab/things-scene": "^10.1.26",
|
|
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.42",
|
|
41
41
|
"three": "^0.185.1"
|
|
42
42
|
},
|
|
43
|
-
"gitHead": "
|
|
43
|
+
"gitHead": "982aa01403b979c4e348fd207a3fb4d1a7b5f521"
|
|
44
44
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **목록이 도형을 그 형식을 고칠 수 있는 모델러로 연다** (2026-09-24, 사용자 지시).
|
|
3
|
+
*
|
|
4
|
+
* 목록은 모든 도형을 V2 모델러로 보냈다. V3 로 바꾼 복사본에서는 어느 카드를 눌러도 V2 화면이 「Figure를 열지
|
|
5
|
+
* 못했습니다 — Cannot read properties of undefined (reading 'x')」로 멈췄고, V3 모델러는 주소를 쳐야만 닿았다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
import { isV3Source, modellerPathOf } from '../client/modeller/modeller-route.ts'
|
|
13
|
+
|
|
14
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
15
|
+
|
|
16
|
+
test('V3 도형은 V3 모델러로, V2 는 V2 로, 새 도형은 V3 로', () => {
|
|
17
|
+
assert.equal(modellerPathOf({ id: 'a', format: 'v3' }), 'figure-v3-modeller/a')
|
|
18
|
+
assert.equal(modellerPathOf({ id: 'a', format: 'v2' }), 'figure-modeller/a')
|
|
19
|
+
assert.equal(modellerPathOf(), 'figure-v3-modeller')
|
|
20
|
+
assert.equal(modellerPathOf({ id: 'a', format: null }), 'figure-modeller/a', '서버가 형식을 모르면 V2 화면이 읽지 못한 까닭을 말한다')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('V3 원본은 자기 판으로 알아본다', () => {
|
|
24
|
+
assert.equal(isV3Source(JSON.stringify({ version: 'figure-v3-asset-1' })), true)
|
|
25
|
+
assert.equal(isV3Source(JSON.stringify({ version: 2 })), false)
|
|
26
|
+
assert.equal(isV3Source('not json'), false)
|
|
27
|
+
assert.equal(isV3Source(undefined), false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('목록은 카드와 새 도형 단추 모두 형식으로 고른다', () => {
|
|
31
|
+
const list = read('../client/pages/figure-list-page.ts')
|
|
32
|
+
assert.match(list, /navigate\(modellerPathOf\(figure\)\)/)
|
|
33
|
+
assert.match(list, /this\.openModeller\(record\)/, '카드는 id 가 아니라 형식을 가진 행을 넘긴다')
|
|
34
|
+
assert.doesNotMatch(list, /navigate\(id \? `figure-modeller/)
|
|
35
|
+
assert.match(read('../client/graphql/index.ts'), /groups\s+format\s+thumbnailUpdatedAt/, '목록 질의가 형식을 받는다')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('V2 주소로 온 V3 도형은 V3 모델러로 넘긴다 — 뒤로 가기가 다시 V2 로 돌지 않게 바꿔 넣는다', () => {
|
|
39
|
+
const v2 = read('../client/pages/figure-modeller-page.ts')
|
|
40
|
+
const load = v2.slice(v2.indexOf('private async load('), v2.indexOf('fromFigureSource(JSON.parse'))
|
|
41
|
+
assert.match(load, /if \(isV3Source\(figure\.source\)\) \{\s*navigate\(modellerPathOf\(\{ id, format: 'v3' \}\), true\)\s*return/)
|
|
42
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **V3 부품 맞추기 — 가운데 · 바닥 · 대칭 · 눈금** (V3 동등표 ③, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* V2 인스펙터의 「맞추기」 여섯 단추. V3 의 틀은 바닥이 y 0, X · Z 가 0 에 가운데다. 각 도구가 부품의 **실제로 차지하는
|
|
5
|
+
* 상자**(돌린 부품이면 돈 부피)를 기준으로 맞추는지, 그 결과를 잰다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
import { addV3Box, createV3Asset, editV3Part } from '@hatiolab/figure-model'
|
|
13
|
+
|
|
14
|
+
import { alignChanges, boxOf, canMirror, gridStepOf, mirrorChanges } from '../client/modeller/v3-align.ts'
|
|
15
|
+
import { gizmoReachOf } from '../client/modeller/v3-gizmo.ts'
|
|
16
|
+
|
|
17
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
18
|
+
const probe = (at = { x: 130, y: 90, z: -70 }, rotation?: { x: number; y: number; z: number }) => {
|
|
19
|
+
let asset = createV3Asset('Probe')
|
|
20
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 200, y: 100, z: 100 }, position: at, ...(rotation ? { rotation } : {}) })
|
|
21
|
+
return asset
|
|
22
|
+
}
|
|
23
|
+
const applied = (asset: ReturnType<typeof probe>, changes: ReturnType<typeof alignChanges>) => {
|
|
24
|
+
const next = structuredClone(asset)
|
|
25
|
+
for (const c of changes) next.designInputs[c.input] = c.value
|
|
26
|
+
return next
|
|
27
|
+
}
|
|
28
|
+
const run = (asset: ReturnType<typeof probe>, tool: Parameters<typeof alignChanges>[3]) =>
|
|
29
|
+
applied(asset, alignChanges(asset, 'body', gizmoReachOf(asset as never, 'body'), tool))
|
|
30
|
+
|
|
31
|
+
test('가운데 X · Z 는 부품 상자의 가운데를 0 에 둔다', () => {
|
|
32
|
+
assert.equal(Math.round(boxOf(run(probe(), 'centre-x'), 'body')!.centre.x), 0)
|
|
33
|
+
assert.equal(Math.round(boxOf(run(probe(), 'centre-z'), 'body')!.centre.z), 0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('바닥은 부품의 가장 낮은 점을 y 0 에 둔다 — 돌린 부품은 돈 부피로', () => {
|
|
37
|
+
const flat = boxOf(run(probe(), 'floor'), 'body')!
|
|
38
|
+
assert.equal(Math.round(flat.centre.y - flat.extent.y / 2), 0)
|
|
39
|
+
const turned = probe({ x: 0, y: 300, z: 0 }, { x: 0, y: 0, z: 45 })
|
|
40
|
+
const landed = boxOf(run(turned, 'floor'), 'body')!
|
|
41
|
+
assert.ok(Math.abs(landed.centre.y - landed.extent.y / 2) <= 1, `45° 돈 부품의 모서리가 바닥에 닿는다 (${landed.centre.y - landed.extent.y / 2})`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('눈금은 V2 와 같은 1 · 2 · 5 간격으로 자리와 크기를 맞추고, 크기를 0 으로 만들지 않는다', () => {
|
|
45
|
+
const asset = probe({ x: 133, y: 47, z: -71 })
|
|
46
|
+
const step = gridStepOf(asset)
|
|
47
|
+
assert.equal(step, 50, '가장 긴 폭 200 을 다섯 칸으로 — 40 이 아니라 50')
|
|
48
|
+
const snapped = run(asset, 'snap')
|
|
49
|
+
assert.deepEqual(['x', 'y', 'z'].map(a => snapped.designInputs[`body.position.${a}`]), [150, 50, -50])
|
|
50
|
+
assert.ok(['x', 'y', 'z'].every(a => snapped.designInputs[`body.size.${a}`]! >= step))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('대칭은 복제본을 가운데 면 반대편 같은 거리에 둔다 · 가운데에 있으면 내지 않는다', () => {
|
|
54
|
+
const asset = probe()
|
|
55
|
+
const copied = editV3Part(asset, { kind: 'duplicate-part', target: 'body' })
|
|
56
|
+
const mirrored = applied(copied, mirrorChanges(copied, 'body', 'body-copy', 'x'))
|
|
57
|
+
assert.equal(Math.round(boxOf(mirrored, 'body-copy')!.centre.x), -Math.round(boxOf(mirrored, 'body')!.centre.x))
|
|
58
|
+
assert.equal(canMirror(probe({ x: 0, y: 50, z: 0 }) as never, 'body', 'x', gizmoReachOf(probe() as never, 'body')), false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('붙인 부품처럼 옮길 수 없는 것은 맞추기도 없다', () => {
|
|
62
|
+
assert.deepEqual(alignChanges(probe(), 'body', { translate: false, rotate: false, scale: false }, 'floor'), [])
|
|
63
|
+
assert.match(read('../client/modeller/v3-editor.ts'), /if \(!this\.selected \|\| !reach\.translate\) return null/)
|
|
64
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **V3 부품을 끌어서 옮기고 · 돌리고 · 키운다** (V3 동등표 ③, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* V2 는 부품을 고르면 기즈모가 붙고 W · E · R 로 옮기기 · 돌리기 · 크기가 갈린다. 끈 결과는 숫자 칸으로 되쓰인다.
|
|
5
|
+
* V3 도 같은 일을 한다 — 되쓸 숫자가 부품 자신의 것일 때만. 붙인 부품처럼 되쓸 수 없는 것은 기즈모가 내지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
import { addV3Box, addV3Primitive, applyV3Authoring, compileV3Asset, createV3Asset, createV3EditorWorkspace } from '@hatiolab/figure-model'
|
|
13
|
+
|
|
14
|
+
import { gizmoChanges, gizmoReachOf } from '../client/modeller/v3-gizmo.ts'
|
|
15
|
+
|
|
16
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
17
|
+
const probe = () => {
|
|
18
|
+
let asset = createV3Asset('Probe')
|
|
19
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
20
|
+
asset = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 150, z: 0 } })
|
|
21
|
+
return asset
|
|
22
|
+
}
|
|
23
|
+
const apply = (asset: ReturnType<typeof probe>, changes: ReturnType<typeof gizmoChanges>) => {
|
|
24
|
+
const next = structuredClone(asset)
|
|
25
|
+
for (const change of changes) next.designInputs[change.input] = change.value
|
|
26
|
+
return next
|
|
27
|
+
}
|
|
28
|
+
const pose = (asset: ReturnType<typeof probe>, id: string) =>
|
|
29
|
+
(compileV3Asset(asset).evaluate().geometry.find(g => (g as { id: string }).id === id) as unknown as { pose: { t: number[]; r: number[][] } }).pose
|
|
30
|
+
|
|
31
|
+
test('만든 상자는 셋 다, 원기둥은 크기를 뺀 둘을 낸다', () => {
|
|
32
|
+
assert.deepEqual(gizmoReachOf(probe() as never, 'body'), {
|
|
33
|
+
translate: true, rotate: true, scale: true, sizes: ['body.size.x', 'body.size.y', 'body.size.z']
|
|
34
|
+
})
|
|
35
|
+
assert.deepEqual(gizmoReachOf(probe() as never, 'drum'), { translate: true, rotate: true, scale: false })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('편집기의 도형 팔레트로 만든 상자 · 둥근 상자도 크기를 낸다 — 입력 이름이 dimension.* 이다', () => {
|
|
39
|
+
let asset = createV3Asset('Probe')
|
|
40
|
+
asset = addV3Primitive(asset, { id: 'box-1', kind: 'box', dimensions: { width: 100, height: 100, depth: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
41
|
+
asset = addV3Primitive(asset, { id: 'rb-1', kind: 'rounded-box', dimensions: { width: 100, height: 60, depth: 80, round: 10 }, position: { x: 0, y: 50, z: 0 } })
|
|
42
|
+
const box = gizmoReachOf(asset as never, 'box-1')
|
|
43
|
+
assert.equal(box.scale, true, '편집기에서 만든 상자를 키울 수 없었다 (2026-09-24, :3300)')
|
|
44
|
+
const changes = gizmoChanges('rb-1', gizmoReachOf(asset as never, 'rb-1'), { scale: [2, 1, 0.5] }, asset.designInputs)
|
|
45
|
+
assert.deepEqual(changes.map(c => [c.input, c.value]), [['rb-1.dimension.width', 200], ['rb-1.dimension.height', 60], ['rb-1.dimension.depth', 40]])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('다른 부품에 붙인 부품은 옮기지 않는다 — 다음 평가가 제자리로 돌려놓을 것을 움직이지 않는다', () => {
|
|
49
|
+
/* attach takes box-shaped parts only. */
|
|
50
|
+
const lidded = addV3Box(probe(), { id: 'lid', size: { x: 100, y: 20, z: 100 }, position: { x: 0, y: 300, z: 0 } })
|
|
51
|
+
assert.equal(gizmoReachOf(lidded as never, 'lid').translate, true, '전제: 붙이기 전에는 옮길 수 있다')
|
|
52
|
+
const fastened = applyV3Authoring(lidded, { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'body', face: 'top' } })
|
|
53
|
+
assert.equal(gizmoReachOf(fastened as never, 'lid').translate, false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('옮긴 자리 그대로 놓인다 — 밀리미터로 맞춘다', () => {
|
|
57
|
+
const asset = probe()
|
|
58
|
+
const changes = gizmoChanges('body', gizmoReachOf(asset as never, 'body'), { position: [120.4, 49.6, -30.2] }, asset.designInputs)
|
|
59
|
+
assert.deepEqual(changes.map(c => c.value), [120, 50, -30])
|
|
60
|
+
assert.deepEqual(pose(apply(asset, changes), 'body').t.map(Math.round), [120, 50, -30])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('돌린 각은 rigid@1 의 X·Y·Z 순서 그대로 입력이 된다 — 0.1° 로 맞춘다', () => {
|
|
64
|
+
const asset = probe()
|
|
65
|
+
const changes = gizmoChanges('body', gizmoReachOf(asset as never, 'body'), { rotation: [0, 30.04, 0] }, asset.designInputs)
|
|
66
|
+
assert.deepEqual(changes.map(c => [c.input, c.value]), [['body.rotation.x', 0], ['body.rotation.y', 30], ['body.rotation.z', 0]])
|
|
67
|
+
const r = pose(apply(asset, changes), 'body').r
|
|
68
|
+
assert.ok(Math.abs(r[0]![2]! - Math.sin(Math.PI / 6)) < 1e-9, 'Y 로 30° 돈 행렬이다')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('크기는 배율만큼 치수를 바꾸고 1mm 아래로 가지 않는다', () => {
|
|
72
|
+
const asset = probe()
|
|
73
|
+
const reach = gizmoReachOf(asset as never, 'body')
|
|
74
|
+
assert.deepEqual(gizmoChanges('body', reach, { scale: [1.5, 1, 0.001] }, asset.designInputs).map(c => c.value), [150, 100, 1])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('끈 것은 세션의 한 걸음이다 — 한 번의 되돌리기로 돌아간다', () => {
|
|
78
|
+
const workspace = createV3EditorWorkspace() as unknown as Record<string, Function>
|
|
79
|
+
assert.ok(typeof workspace.apply === 'function')
|
|
80
|
+
const editor = read('../client/modeller/v3-editor.ts')
|
|
81
|
+
assert.match(editor, /if \(changes\.length\) this\.run\(\(\) => this\.session!\.apply\(changes\)\)/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('캔버스 — W · E · R, 끄는 동안 궤도 조작을 멈추고, 놓는 순간은 고르기가 아니다', () => {
|
|
85
|
+
const canvas = read('../client/modeller/v3-canvas.ts')
|
|
86
|
+
assert.match(canvas, /\{ w: 'translate', e: 'rotate', r: 'scale' \}/)
|
|
87
|
+
assert.match(canvas, /this\.controls\.enabled = !on/)
|
|
88
|
+
assert.match(canvas, /if \(this\.justDragged \|\| this\.transform\?\.dragging\) return/)
|
|
89
|
+
assert.match(canvas, /setFromQuaternion\(this\.proxy\.quaternion, 'XYZ'\)/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('기즈모의 말은 다섯 말 모두에 있다', () => {
|
|
93
|
+
for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
|
|
94
|
+
const table = JSON.parse(read(`../translations/${lang}.json`))
|
|
95
|
+
for (const mode of ['translate', 'rotate', 'scale']) assert.ok(table[`figure.label.gizmo-${mode}`], `${lang}: ${mode}`)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **V3 부품의 재질을 V2 처럼 고친다** (V3 동등표 ③, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* V3 편집기는 색 역할을 날것의 이름(`palette.dark`)으로 고르는 칸 하나였다. 재질 칸이 내는 편집이 figure-model 에서
|
|
5
|
+
* 정말로 받아들여지고, 그려지는 것을 바꾸는지를 잰다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
import { addV3Box, addV3Primitive, compileV3Asset, createV3Asset, editV3Part, v3CostOf } from '@hatiolab/figure-model'
|
|
13
|
+
|
|
14
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
15
|
+
const material = read('../client/modeller/figure-v3-material.ts')
|
|
16
|
+
const editor = read('../client/modeller/v3-editor.ts')
|
|
17
|
+
|
|
18
|
+
const probe = () => {
|
|
19
|
+
let asset = createV3Asset('Probe')
|
|
20
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
21
|
+
asset = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
22
|
+
return asset
|
|
23
|
+
}
|
|
24
|
+
const drawn = (asset: ReturnType<typeof probe>, id: string) =>
|
|
25
|
+
compileV3Asset(asset).evaluate().geometry.find(g => (g as { id: string }).id === id)!
|
|
26
|
+
|
|
27
|
+
test('재질 칸이 보내는 모양 그대로 figure-model 이 받고, 그려지는 재질이 바뀐다', () => {
|
|
28
|
+
const edited = editV3Part(probe(), {
|
|
29
|
+
kind: 'set-material',
|
|
30
|
+
target: 'body',
|
|
31
|
+
material: { token: 'palette.accent', preset: 'metal', transparent: true, emissive: { token: 'palette.accent', intensity: 0.5, on: true } },
|
|
32
|
+
/* A new asset carries only its primary colour — the section sends the one it names (figure-model 0.1.42). */
|
|
33
|
+
palette: { 'palette.accent': '#f28c28' }
|
|
34
|
+
})
|
|
35
|
+
assert.deepEqual(drawn(edited, 'body').material, {
|
|
36
|
+
token: 'palette.accent',
|
|
37
|
+
preset: 'metal',
|
|
38
|
+
transparent: true,
|
|
39
|
+
emissive: { token: 'palette.accent', intensity: 0.5, on: true }
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('발광 세기 0 은 발광을 없앤다 — 빈 칸을 남기지 않는다(형식이 undefined 칸을 거절한다)', () => {
|
|
44
|
+
assert.match(material, /emissive: intensity > 0 \? \{[^}]*\} : undefined/)
|
|
45
|
+
assert.match(material, /if \(material\[key\] === undefined\) delete material\[key\]/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('곡면 분할은 둥근 몸체에만 내고, 고르면 삼각형 수가 그만큼 바뀐다', () => {
|
|
49
|
+
assert.match(material, /const ROUND = new Set\(\['cylinder', 'ellipticCylinder', 'sphere', 'frustum'\]\)/)
|
|
50
|
+
const coarse = v3CostOf(editV3Part(probe(), { kind: 'set-segments', target: 'drum', segments: 8 })).triangles
|
|
51
|
+
const fine = v3CostOf(editV3Part(probe(), { kind: 'set-segments', target: 'drum', segments: 48 })).triangles
|
|
52
|
+
assert.ok(fine > coarse, `48 분할이 8 분할보다 삼각형이 많다 (${coarse} → ${fine})`)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('색 역할은 날것의 이름이 아니라 쓰임새로 부른다', () => {
|
|
56
|
+
assert.match(material, /title=\$\{purposeOf\(token\)\}/)
|
|
57
|
+
assert.doesNotMatch(editor, /token === 'palette\.primary' \? '기본 몸체' : token/, '옛 날것 이름 고르개가 남아 있다')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('편집기는 재질 칸의 편집을 세션의 이력 하나로 돌린다', () => {
|
|
61
|
+
assert.match(editor, /<figure-v3-material[\s\S]*?@v3-part-edit=\$\{\(e: CustomEvent\) => this\.run\(\(\) => this\.session!\.edit\(e\.detail\.edit\)\)\}/)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('자산에 없는 색 역할은 그 색을 함께 보낸다 — 새 도형의 팔레트는 기본색 하나다', () => {
|
|
65
|
+
assert.match(material, /return \{ \.\.\.\(activePalette\(\) as Record<string, string>\), \.\.\.this\.palette \}/, '앱 팔레트를 보인다')
|
|
66
|
+
assert.match(material, /this\.palette\[token\] === undefined && offered\[token\] !== undefined/)
|
|
67
|
+
assert.match(material, /\.\.\.\(Object\.keys\(palette\)\.length \? \{ palette \} : \{\}\)/)
|
|
68
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **V3 편집기의 부품 목록은 V2 의 목록이다** (V3 동등표 ③, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* `figure-v3-parts` 는 V2 목록과 같은 자리 · 같은 조작 · 같은 사건 이름으로 지어졌지만(fdc15e162e) 편집기 어디에도
|
|
5
|
+
* 걸리지 않았다. 편집기는 맨 단추 네 개와 이름 단추 목록이었다. 패널이 내는 사건마다 받는 자리가 있어야 한다 —
|
|
6
|
+
* 없으면 눌러도 아무 일이 없다.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
14
|
+
const editor = read('../client/modeller/v3-editor.ts')
|
|
15
|
+
const panel = read('../client/modeller/figure-v3-parts.ts')
|
|
16
|
+
|
|
17
|
+
test('패널이 내는 사건마다 편집기에 받는 자리가 있다', () => {
|
|
18
|
+
const asked = new Set([...panel.matchAll(/this\.ask\('([a-z-]+)'/g)].map(m => m[1]))
|
|
19
|
+
for (const name of [...panel.matchAll(/op\('([a-z-]+)'/g)].map(m => m[1])) asked.add(name)
|
|
20
|
+
assert.ok(asked.size >= 8, `전제: 패널의 사건을 읽었다 (${[...asked].join(', ')})`)
|
|
21
|
+
const listener = editor.slice(editor.indexOf('<figure-v3-parts'), editor.indexOf('</figure-v3-parts>'))
|
|
22
|
+
for (const name of asked) assert.ok(listener.includes(`@${name}=`), `${name} 을 받는 자리가 없다`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('복제 · 순서 · 삭제는 figure-model 의 부품 편집으로, 세션을 지난다', () => {
|
|
26
|
+
assert.match(editor, /this\.session!\.edit\(\{ kind: 'duplicate-part', target: id \}\)/)
|
|
27
|
+
assert.match(editor, /this\.session!\.edit\(\{ kind: 'move-part', target: id, to \}\)/)
|
|
28
|
+
assert.match(editor, /this\.session!\.edit\(\{ kind: 'remove-part', target: id \}\)/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('작업대에서 가린 부품은 캔버스에서 빠지고, 다른 도형을 열면 풀린다', () => {
|
|
32
|
+
assert.match(editor, /drawableV3Parts\(view\.geometry \?\? \[\]\)\.filter\(body => !this\.hiddenParts\.has/)
|
|
33
|
+
const reset = editor.slice(editor.indexOf('protected willUpdate'), editor.indexOf('private run('))
|
|
34
|
+
assert.match(reset, /this\.hiddenParts = new Set\(\)/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('지울 수 없는 부품은 사람의 말로 거절한다 — 다섯 말 모두', () => {
|
|
38
|
+
assert.match(editor, /code === 'PART_REFERENCED'[\s\S]*?figure\.text\.part-is-referenced/)
|
|
39
|
+
for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
|
|
40
|
+
const table = JSON.parse(read(`../translations/${lang}.json`))
|
|
41
|
+
assert.ok(table['figure.text.part-is-referenced']?.includes('{part}'), lang)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('좁은 칸에서도 고른 줄의 이름과 조작이 다 보인다 — 조작은 이름 아래 줄로', () => {
|
|
46
|
+
assert.match(panel, /container-type: inline-size/)
|
|
47
|
+
const narrow = panel.slice(panel.indexOf('@container (max-width: 199px)'))
|
|
48
|
+
assert.match(narrow, /li\[selected\] \{\s*flex-wrap: wrap;/)
|
|
49
|
+
assert.match(narrow, /li\[selected\] div\[ops\] \{\s*flex-basis: 100%;/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('둥근 상자는 제 이름으로 부른다 — 「사각 단면」은 V2 의 다른 도형이다', () => {
|
|
53
|
+
assert.match(panel, /kind: 'rounded-box', icon: 'crop_square', label: 'figure\.label\.primitive-rounded-box'/)
|
|
54
|
+
for (const lang of ['ko', 'en', 'ja', 'zh', 'ms'])
|
|
55
|
+
assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.label.primitive-rounded-box'], lang)
|
|
56
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **V3 화면에서 도형의 속성을 고친다** (V3 동등표 ②, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* V2 인스펙터는 이름 · 배치 기준 · 차지하는 공간 · 분류 · 태그를 맨 위에 둔다. V3 화면에는 타입 칸 하나뿐이었다.
|
|
5
|
+
* 두 축을 본다: 탭이 내는 명령이 figure-model 에서 **정말로** 그 일을 하는가, 그리고 화면이 명령과 행 쓰기를 잇는가.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
import { addV3Box, applyV3Authoring, createV3Asset, inspectV3Asset, parseV3Asset, serializeV3Asset } from '@hatiolab/figure-model'
|
|
13
|
+
|
|
14
|
+
import { tagsOf, v3FigureFacts } from '../client/modeller/v3-figure-facts.ts'
|
|
15
|
+
import { occupancyFromParts } from '../client/modeller/v3-occupancy.ts'
|
|
16
|
+
|
|
17
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
18
|
+
const probe = () => {
|
|
19
|
+
let asset = createV3Asset('Probe')
|
|
20
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
21
|
+
return asset
|
|
22
|
+
}
|
|
23
|
+
const facts = (asset: ReturnType<typeof probe>) => v3FigureFacts(serializeV3Asset(asset))
|
|
24
|
+
|
|
25
|
+
test('탭이 읽는 사실 — 선언 전에는 배치 기준도 장착면도 「없음」이다', () => {
|
|
26
|
+
const read = facts(probe())
|
|
27
|
+
assert.equal(read.name, 'Probe')
|
|
28
|
+
assert.deepEqual(read.parts, ['body'])
|
|
29
|
+
assert.equal(read.declared, false)
|
|
30
|
+
assert.equal(read.placement, undefined, '없는 것을 바닥으로 읽지 않는다')
|
|
31
|
+
assert.equal(read.mounting, undefined)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('배치 기준을 고르면 그 기준으로 공간을 선언한다 — 판정도 풀린다', () => {
|
|
35
|
+
const source = serializeV3Asset(probe())
|
|
36
|
+
const declared = applyV3Authoring(parseV3Asset(source), occupancyFromParts(source, 'ceiling'))
|
|
37
|
+
|
|
38
|
+
assert.equal(facts(declared).placement, 'ceiling')
|
|
39
|
+
assert.equal(facts(declared).declared, true)
|
|
40
|
+
assert.ok(!inspectV3Asset(declared).violations.some(v => v.code === 'OCCUPANCY_UNDECLARED'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('장착면을 정하면 탭이 그 부품과 면을 다시 읽고, 해제하면 없어진다', () => {
|
|
44
|
+
const source = serializeV3Asset(probe())
|
|
45
|
+
const declared = applyV3Authoring(parseV3Asset(source), occupancyFromParts(source))
|
|
46
|
+
const mounted = applyV3Authoring(declared, { kind: 'set-mounting-face', part: 'body', face: 'bottom' })
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(facts(mounted).mounting, { part: 'body', face: 'bottom' })
|
|
49
|
+
assert.equal(facts(applyV3Authoring(mounted, { kind: 'clear-mounting-face' })).mounting, undefined)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('이름을 바꾸면 자산의 이름이 바뀐다 — 저장하면 목록 이름이 이것을 따른다', () => {
|
|
53
|
+
assert.equal(facts(applyV3Authoring(probe(), { kind: 'rename', name: 'Roller conveyor' })).name, 'Roller conveyor')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('태그 줄은 V2 와 같은 규칙으로 목록이 된다', () => {
|
|
57
|
+
assert.deepEqual(tagsOf('#반출, 컨베이어,, 반출 '), ['반출', '컨베이어'])
|
|
58
|
+
assert.deepEqual(tagsOf(''), [], '빈 줄은 전부 지우는 것이다')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const page = read('../client/pages/figure-v3-modeller-page.ts')
|
|
62
|
+
|
|
63
|
+
test('화면이 속성 탭을 걸고, 명령은 세션의 이력 하나로, 분류 · 태그는 행으로 간다', () => {
|
|
64
|
+
assert.ok(/renderTab\('properties', 'tune'/.test(page), '속성 탭')
|
|
65
|
+
assert.ok(/<figure-v3-properties\s+\?off=/.test(page), '감추되 떼지 않는다')
|
|
66
|
+
assert.ok(/@v3-command=\$\{this\.onCommand\}/.test(page) && /@v3-catalog=\$\{this\.onCatalog\}/.test(page))
|
|
67
|
+
const run = page.slice(page.indexOf('private runCommand'), page.indexOf('private onCatalog'))
|
|
68
|
+
assert.ok(/this\.session\.command\(actionOf\(\)\)/.test(run), '명령은 세션을 지난다 — 되돌리기가 먹는다')
|
|
69
|
+
const catalog = page.slice(page.indexOf('private onCatalog'), page.indexOf('private released'))
|
|
70
|
+
assert.ok(/updateFigure\(id, patch\)/.test(catalog), '분류 · 태그는 행에 쓴다')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('이름을 바꾸면 제목 줄도 바뀐다 — 셸은 알려 줄 때만 제목을 다시 읽는다', () => {
|
|
74
|
+
const changed = page.slice(page.indexOf('private changed = () =>'), page.indexOf('private async readRow'))
|
|
75
|
+
assert.ok(/view\.name !== this\.titled[\s\S]*this\.updateContext\(\)/.test(changed))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('발행한 뒤에도 행의 타입이 남는다 — 새로 짜면 잠긴 타입 칸이 빈다', () => {
|
|
79
|
+
const released = page.slice(page.indexOf('private released'), page.indexOf('private reverted'))
|
|
80
|
+
assert.ok(/this\.row = \{ \.\.\.this\.row, state:/.test(released))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('속성 탭의 말은 다섯 말 모두에 있다', () => {
|
|
84
|
+
const tab = read('../client/modeller/figure-v3-properties.ts')
|
|
85
|
+
assert.ok(tab.includes('occupancyFromParts(this.source, placement)'), '고른 배치 기준으로 선언한다')
|
|
86
|
+
const keys = [...tab.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
|
|
87
|
+
for (const face of ['left', 'right', 'bottom', 'top', 'back', 'front']) keys.push(`figure.label.face-${face}`)
|
|
88
|
+
for (const one of ['floor', 'ceiling', 'center']) keys.push(`figure.label.placement-${one}`)
|
|
89
|
+
for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
|
|
90
|
+
const table = JSON.parse(read(`../translations/${lang}.json`))
|
|
91
|
+
for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **V3 모델러는 테마를 따른다** (V3 동등표 ⑦, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* V3 캔버스와 편집기는 색을 박아 두었다. 어두운 테마에서 가운데만 밝은 판이 떴고, 경계선과 눌린 단추 · 오류 칸도
|
|
5
|
+
* 밝은 색 그대로였다. 색은 셸의 토큰에서 온다(UX 결정 — 바탕은 surface-container, 눌림은 secondary-container,
|
|
6
|
+
* 오류는 error-container).
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
14
|
+
const canvas = read('../client/modeller/v3-canvas.ts')
|
|
15
|
+
const editor = read('../client/modeller/v3-editor.ts')
|
|
16
|
+
|
|
17
|
+
/** Hex colours that are not a `var(--token, #fallback)` fallback. */
|
|
18
|
+
const fixed = (text: string) =>
|
|
19
|
+
text
|
|
20
|
+
.replace(/var\(--[\w-]+,\s*#[0-9a-fA-F]{3,8}\)/g, '')
|
|
21
|
+
.match(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![\w-])/g) ?? []
|
|
22
|
+
|
|
23
|
+
test('캔버스와 편집기에 박힌 색이 없다 — 토큰의 대체값만 남는다', () => {
|
|
24
|
+
assert.deepEqual(fixed(canvas), [], 'v3-canvas.ts')
|
|
25
|
+
assert.deepEqual(fixed(editor), [], 'v3-editor.ts')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('캔버스 바탕은 CSS 와 3D 가 같은 토큰에서 온다', () => {
|
|
29
|
+
assert.match(canvas, /background: var\(--md-sys-color-surface-container\)/)
|
|
30
|
+
assert.match(canvas, /this\.scene\.background = new THREE\.Color\(colour\)/)
|
|
31
|
+
assert.match(canvas, /getComputedStyle\(this\)\.backgroundColor/, '3D 바탕이 호스트의 바탕을 읽는다')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('테마를 바꾸면 3D 바탕을 다시 읽는다 — 한 번만 읽으면 가운데만 밝은 판이 남는다', () => {
|
|
35
|
+
assert.match(canvas, /observe\(document\.body, \{ attributes: true, attributeFilter: \['class'\] \}\)/)
|
|
36
|
+
const onChange = canvas.slice(canvas.indexOf('new MutationObserver'), canvas.indexOf('this.themeObserver.observe'))
|
|
37
|
+
assert.match(onChange, /this\.paintBackground\(\)/)
|
|
38
|
+
assert.match(onChange, /this\.draw\(\)/, '다시 읽고 그리지 않으면 다음 움직임까지 옛 색이다')
|
|
39
|
+
const leave = canvas.slice(canvas.indexOf('disconnectedCallback()'))
|
|
40
|
+
assert.match(leave, /this\.themeObserver\?\.disconnect\(\)/, '떠난 캔버스가 body 를 계속 지켜본다')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('눌린 단추와 오류 칸은 셸이 두 테마 대비를 맞춘 칸을 쓴다', () => {
|
|
44
|
+
const pressed = editor.slice(editor.indexOf("button[aria-pressed='true']"), editor.indexOf('}', editor.indexOf("button[aria-pressed='true']")))
|
|
45
|
+
assert.match(pressed, /--md-sys-color-secondary-container\)/)
|
|
46
|
+
assert.match(pressed, /--md-sys-color-on-secondary-container\)/)
|
|
47
|
+
assert.match(pressed, /border-color: var\(--md-sys-color-primary\)/)
|
|
48
|
+
const error = editor.slice(editor.indexOf('.error {'), editor.indexOf('}', editor.indexOf('.error {')))
|
|
49
|
+
assert.match(error, /--md-sys-color-error-container\)/)
|
|
50
|
+
assert.match(error, /--md-sys-color-on-error-container\)/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('편집기는 창이 아니라 자기 폭으로 접는다 — 레일과 오른쪽 칸이 폭을 먼저 가져간다', () => {
|
|
54
|
+
assert.match(editor, /container-type: inline-size/)
|
|
55
|
+
assert.doesNotMatch(editor, /@media \(max-width/, '창 폭 기준이 남아 있다 — 레일을 펼치면 편집기가 오른쪽 칸 위로 넘친다')
|
|
56
|
+
assert.match(editor, /@container \(max-width: 659px\)/, '세 칸의 최소 폭 합(660) 아래에서 두 칸')
|
|
57
|
+
const one = editor.slice(editor.indexOf('@container (max-width: 419px)'))
|
|
58
|
+
assert.match(one, /grid-template-columns: minmax\(0, 1fr\)/, '420 아래에서 한 줄')
|
|
59
|
+
assert.match(one, /figure-v3-canvas \{\s*order: -1;/, '한 줄에서는 캔버스가 먼저')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('넘침이 없으니 오른쪽 칸은 편집기를 가리지 않는다 — 가림은 우회였다', () => {
|
|
63
|
+
const page = read('../client/pages/figure-v3-modeller-page.ts')
|
|
64
|
+
const aside = page.slice(page.indexOf(' aside {'), page.indexOf('}', page.indexOf(' aside {')))
|
|
65
|
+
assert.doesNotMatch(aside, /z-index/)
|
|
66
|
+
assert.match(aside, /border-left: 1px solid var\(--md-sys-color-outline-variant\)/, '칸 경계선은 남는다')
|
|
67
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -36,6 +36,17 @@
|
|
|
36
36
|
"figure.button.settle-on-floor": "settle on the floor",
|
|
37
37
|
"figure.button.snap": "snap",
|
|
38
38
|
"figure.button.take-n-changes": "apply {n}",
|
|
39
|
+
"figure.label.declared-space": "declared space",
|
|
40
|
+
"figure.label.gizmo-rotate": "turn",
|
|
41
|
+
"figure.label.gizmo-scale": "size",
|
|
42
|
+
"figure.label.gizmo-translate": "move",
|
|
43
|
+
"figure.label.primitive-rounded-box": "rounded box",
|
|
44
|
+
"figure.text.catalog-needs-a-save": "can be set once the figure is saved.",
|
|
45
|
+
"figure.text.declared-space-declared": "declared from the parts.",
|
|
46
|
+
"figure.text.declared-space-none": "not declared yet.",
|
|
47
|
+
"figure.text.mounting-face-needs-space": "declare the space first to choose one.",
|
|
48
|
+
"figure.text.part-is-referenced": "'{part}' cannot be deleted: another part is fastened to it or follows its size. Undo that link first.",
|
|
49
|
+
"figure.text.placement-redeclares": "changing it declares the space again from the parts as they are now.",
|
|
39
50
|
"figure.text.recommended-fix-explained": "{n} changes rechecked against every resize case. They apply to this draft only; review and save when ready.",
|
|
40
51
|
"figure.text.show-affected-parts": "show affected parts",
|
|
41
52
|
"figure.button.to-floor": "to floor",
|
package/translations/ja.json
CHANGED
|
@@ -34,6 +34,17 @@
|
|
|
34
34
|
"figure.button.settle-on-floor": "床に置く",
|
|
35
35
|
"figure.button.snap": "snap",
|
|
36
36
|
"figure.button.take-n-changes": "apply {n}",
|
|
37
|
+
"figure.label.declared-space": "空間宣言",
|
|
38
|
+
"figure.label.gizmo-rotate": "回転",
|
|
39
|
+
"figure.label.gizmo-scale": "サイズ",
|
|
40
|
+
"figure.label.gizmo-translate": "移動",
|
|
41
|
+
"figure.label.primitive-rounded-box": "角丸ボックス",
|
|
42
|
+
"figure.text.catalog-needs-a-save": "保存した後に設定できます。",
|
|
43
|
+
"figure.text.declared-space-declared": "部品から宣言しました。",
|
|
44
|
+
"figure.text.declared-space-none": "まだ宣言していません。",
|
|
45
|
+
"figure.text.mounting-face-needs-space": "先に空間を宣言すると選べます。",
|
|
46
|
+
"figure.text.part-is-referenced": "'{part}' には他の部品が取り付けられているか、そのサイズに従っているため削除できません。先にその連結を外してください。",
|
|
47
|
+
"figure.text.placement-redeclares": "変更すると、今の部品で空間を宣言し直します。",
|
|
37
48
|
"figure.text.recommended-fix-explained": "すべてのサイズ変更ケースを再検査した {n} 件の修正です。下書きだけに適用され、保存前に確認できます。",
|
|
38
49
|
"figure.text.show-affected-parts": "影響する部品を見る",
|
|
39
50
|
"figure.button.to-floor": "to floor",
|
package/translations/ko.json
CHANGED
|
@@ -36,6 +36,17 @@
|
|
|
36
36
|
"figure.button.settle-on-floor": "바닥에 앉히기",
|
|
37
37
|
"figure.button.snap": "눈금에",
|
|
38
38
|
"figure.button.take-n-changes": "{n}개 적용",
|
|
39
|
+
"figure.label.declared-space": "공간 선언",
|
|
40
|
+
"figure.label.gizmo-rotate": "돌리기",
|
|
41
|
+
"figure.label.gizmo-scale": "크기",
|
|
42
|
+
"figure.label.gizmo-translate": "옮기기",
|
|
43
|
+
"figure.label.primitive-rounded-box": "둥근 상자",
|
|
44
|
+
"figure.text.catalog-needs-a-save": "저장한 뒤에 정할 수 있습니다.",
|
|
45
|
+
"figure.text.declared-space-declared": "부품으로 선언했습니다.",
|
|
46
|
+
"figure.text.declared-space-none": "아직 선언하지 않았습니다.",
|
|
47
|
+
"figure.text.mounting-face-needs-space": "공간을 먼저 선언해야 고를 수 있습니다.",
|
|
48
|
+
"figure.text.part-is-referenced": "'{part}' 에 다른 부품이 붙어 있거나 그 크기를 따르고 있어 지울 수 없습니다. 그 연결을 먼저 푸세요.",
|
|
49
|
+
"figure.text.placement-redeclares": "바꾸면 지금 부품으로 공간을 다시 선언합니다.",
|
|
39
50
|
"figure.text.recommended-fix-explained": "서버가 전체 크기 변경을 다시 검사한 {n}개 수정입니다. 초안에만 적용되며 저장 전 확인할 수 있습니다.",
|
|
40
51
|
"figure.text.show-affected-parts": "영향 받는 부품 보기",
|
|
41
52
|
"figure.button.to-floor": "바닥에",
|