@scenography/plugin-artwork 0.5.0 → 0.7.0
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/dist/{data-BsI0PbuK.mjs → data-D1hNczp3.mjs} +6 -3
- package/dist/data-D1hNczp3.mjs.map +1 -0
- package/dist/{data-WcCPqQ_k.cjs → data-NIfEarkI.cjs} +5 -2
- package/dist/data-NIfEarkI.cjs.map +1 -0
- package/dist/data.cjs +1 -1
- package/dist/data.d.cts +1 -1
- package/dist/data.d.mts +1 -1
- package/dist/data.mjs +1 -1
- package/dist/{index-XUkegdM8.d.cts → index-Dl6G3iTG.d.cts} +12 -1
- package/dist/{index-XUkegdM8.d.mts → index-Dl6G3iTG.d.mts} +12 -1
- package/dist/index.cjs +93 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.mts +6 -2
- package/dist/index.mjs +94 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/dist/data-BsI0PbuK.mjs.map +0 -1
- package/dist/data-WcCPqQ_k.cjs.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eachPropIs, existsProp, fail, isFiniteNumber, isIntegerNumber, isMeasure, isNonNegativeNumber, isObject, isOneOf, isPositiveNumber, isString, isVec2, isVec3, propIs, validate, when } from "@scenography/scene";
|
|
1
|
+
import { eachPropIs, existsProp, fail, isFiniteNumber, isIntegerNumber, isMeasure, isNonNegativeNumber, isObject, isOneOf, isPositiveNumber, isString, isVec2, isVec3, propIs, surfaceTextureUrls, validate, when } from "@scenography/scene";
|
|
2
2
|
import { allEdgeNames } from "@scenography/plugin-wall/data";
|
|
3
3
|
//#region src/data/artwork.ts
|
|
4
4
|
/** Default visibility when a label/card doesn't specify its own `fields`. */
|
|
@@ -41,6 +41,8 @@ const validateArtwork = (artwork, edgeNames) => validate([
|
|
|
41
41
|
existsProp("size", "Artwork is missing a size."),
|
|
42
42
|
propIs("size", isVec2, "size must be [width, height]."),
|
|
43
43
|
propIs("depth", isNonNegativeNumber, "depth must be a non-negative number."),
|
|
44
|
+
propIs("nativeSrc", isString, "nativeSrc must be a string."),
|
|
45
|
+
propIs("detailDistance", isPositiveNumber, "detailDistance must be a positive number."),
|
|
44
46
|
propIs("year", isIntegerNumber, "year must be an integer."),
|
|
45
47
|
propIs("href", isString, "href must be a string."),
|
|
46
48
|
propIs("availability", isOneOf(AVAILABILITY), "availability must be 'for-sale', 'sold' or 'not-for-sale'."),
|
|
@@ -79,9 +81,10 @@ const validateArtworkInScene = (artwork, ctx) => {
|
|
|
79
81
|
const artworkData = {
|
|
80
82
|
kind: "artworks",
|
|
81
83
|
cardinality: "many",
|
|
82
|
-
validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx)
|
|
84
|
+
validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx),
|
|
85
|
+
assets: (artwork) => [artwork.src, ...surfaceTextureUrls(artwork.frame?.texture)]
|
|
83
86
|
};
|
|
84
87
|
//#endregion
|
|
85
88
|
export { DEFAULT_ARTWORK_FIELDS as i, validateArtwork as n, validateArtworkInScene as r, artworkData as t };
|
|
86
89
|
|
|
87
|
-
//# sourceMappingURL=data-
|
|
90
|
+
//# sourceMappingURL=data-D1hNczp3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-D1hNczp3.mjs","names":[],"sources":["../src/data/artwork.ts","../src/data/validate.ts","../src/data/plugin.ts"],"sourcesContent":["import type { Measure, SurfaceTexture, Vec2, Vec3 } from \"@scenography/scene\";\n\n/**\n * Where an artwork is placed. Extensible via `type`; today a piece either hangs\n * on a named wall edge or floats freely. Future element types (floor, ceiling…)\n * add new variants here.\n */\nexport type ArtworkLocation =\n | {\n type: \"wall\";\n /** Name of the `NamedEdge` to hang on. */\n edge: string;\n /** Position along the edge: a number is units from the edge start, `'50%'` is a fraction. */\n position: Measure;\n /**\n * Vertical placement: a number is an absolute world Y, `'50%'` is a\n * fraction of the wall height. Omitted defaults to the camera's eye height.\n */\n height?: Measure;\n /**\n * Which face of the wall the piece shows on, relative to the edge\n * direction. `'right'` (default) is the right-hand side when walking the\n * edge from its start vertex to its end vertex.\n */\n side?: \"right\" | \"left\";\n }\n | {\n type: \"floating\";\n /** World position `[x, y, z]`. */\n point: Vec3;\n /** Y-axis rotation in radians. Defaults to `0`. */\n yaw?: number;\n };\n\n/**\n * A passepartout (mat) sitting between the frame and the image: a flat border\n * that insets the image. Only meaningful when the artwork has a `frame`.\n */\nexport interface Passepartout {\n /** Mat border width around the image, in world units. Defaults to `6`. */\n width?: number;\n /** Mat color (CSS). Defaults to an off-white. */\n color?: string;\n}\n\n/**\n * A frame around an artwork. By default it's four flat bars rendered with a\n * solid `color` or a `texture` (e.g. wood). For an ornate, sculpted frame, point\n * `model` at an external glTF/GLB instead. A `passepartout` mat can sit between\n * the frame and the image.\n */\nexport interface Frame {\n /** Border width (how wide the frame face is), in world units. Defaults to `5`. */\n width?: number;\n /** How far the frame stands proud of the artwork face, in world units. Defaults to `3`. */\n depth?: number;\n /** Frame color (CSS). Defaults to a dark tone. Ignored when `texture` or `model` is set. */\n color?: string;\n /** Surface texture (e.g. wood); takes visual precedence over `color`. */\n texture?: SurfaceTexture;\n /** External glTF/GLB model for an ornate frame; replaces the generated bars. */\n model?: string;\n /** Passepartout mat between the frame and the image. */\n passepartout?: Passepartout;\n}\n\n/**\n * Commercial status of a piece. Gallery convention: a colored dot signals\n * availability. The dot's color is presentation (decided by the renderer), not\n * data — this is the state it derives from.\n */\nexport type Availability = \"for-sale\" | \"sold\" | \"not-for-sale\";\n\n/**\n * The fields a label/info card can show, identified by name. A `fields` array\n * toggles which are visible; the render order is fixed (it is not the array\n * order): title, then author/year, then technique + dimensions on one line, with\n * availability shown as a corner dot. Shared by the physical label (engine) and\n * the overlay info card.\n */\nexport type ArtworkField = \"title\" | \"author\" | \"year\" | \"technique\" | \"dimensions\" | \"availability\";\n\n/** Default visibility when a label/card doesn't specify its own `fields`. */\nexport const DEFAULT_ARTWORK_FIELDS: readonly ArtworkField[] = [\n \"title\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n];\n\n/**\n * A physical wall label (cartela) materialized next to the piece. Its presence\n * is the opt-in; the engine renders it from the artwork's own data.\n */\nexport interface ArtworkLabel {\n /** Where the label sits relative to the piece. Defaults to `'below'`. */\n side?: \"below\" | \"left\" | \"right\";\n /** Which fields to show. Defaults to `DEFAULT_ARTWORK_FIELDS`. */\n fields?: ArtworkField[];\n /**\n * Size multiplier over the real physical size (24pt title etc). Defaults to\n * `1` (true to life), but a real cartela is only a few cm tall — tiny and hard\n * to read/click at gallery scale — so bump this (e.g. `3`) for legibility.\n */\n scale?: number;\n /**\n * Fine-tune the placement with an `[x, y]` shift in world units, added on top\n * of the `side` position (the frame is already cleared). `x` runs along the\n * wall (right is positive), `y` is vertical (up is positive).\n */\n offset?: Vec2;\n}\n\n/** A framed image placed somewhere in the scene. */\nexport interface Artwork {\n id: string;\n location: ArtworkLocation;\n /** Image URL/path to display. */\n src: string;\n /**\n * Full-resolution source for progressive delivery. When present, `src` is a\n * lightweight preview and the plugin swaps this one in when the piece comes\n * within `detailDistance` of the camera.\n */\n nativeSrc?: string;\n /**\n * Distance, in world units, within which `nativeSrc` is loaded. Defaults to\n * 450. Ignored when `nativeSrc` is absent.\n */\n detailDistance?: number;\n /** `[width, height]` of the artwork, in world units. */\n size: Vec2;\n /**\n * Box depth (volume) in world units: the piece is a box with the image on the\n * front and white sides/back. Defaults to `2`.\n */\n depth?: number;\n /** Optional frame around the artwork. */\n frame?: Frame;\n title?: string;\n technique?: string;\n /** Author / artist name. */\n author?: string;\n /** Year the piece was made. */\n year?: number;\n /**\n * Real-world dimensions for display, e.g. `'92 × 73 cm'`. Distinct from `size`,\n * which is in world units.\n */\n dimensions?: string;\n /** Commercial status; drives the availability dot. */\n availability?: Availability;\n /** Long-form caption / catalog entry, shown in the expanded info panel. */\n description?: string;\n /**\n * External URL for the piece (a catalog page, or a marketplace listing when\n * it's for sale). Surfaced as a link in the expanded info panel.\n */\n href?: string;\n /** When present, a physical wall label is materialized next to the piece. */\n label?: ArtworkLabel;\n /**\n * Max distance (world units) from which the piece can be clicked to open its\n * info panel. A gate so far-away pieces aren't interactive. Absent = no gate.\n */\n infoRadius?: number;\n}\n\ndeclare module \"@scenography/scene\" {\n interface Scene {\n /** Framed images placed in the space — provided by `@scenography/plugin-artwork`. */\n artworks?: Artwork[];\n }\n}\n","import {\n eachPropIs,\n existsProp,\n fail,\n isFiniteNumber,\n isIntegerNumber,\n isMeasure,\n isNonNegativeNumber,\n isObject,\n isOneOf,\n isPositiveNumber,\n isString,\n isVec2,\n isVec3,\n propIs,\n validate,\n when,\n type SceneContext,\n type ValidationResult,\n} from \"@scenography/scene\";\nimport { allEdgeNames, type Wall } from \"@scenography/plugin-wall/data\";\n\nimport type { Artwork, ArtworkField, Availability } from \"./artwork\";\n\nconst AVAILABILITY: ReadonlySet<Availability> = new Set<Availability>([\n \"for-sale\",\n \"sold\",\n \"not-for-sale\",\n]);\nconst ARTWORK_FIELDS: ReadonlySet<ArtworkField> = new Set<ArtworkField>([\n \"title\",\n \"author\",\n \"year\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n]);\nconst LABEL_SIDES: ReadonlySet<string> = new Set([\"below\", \"left\", \"right\"]);\nconst LOCATION_TYPES: ReadonlySet<string> = new Set([\"wall\", \"floating\"]);\n\nconst locationOfType =\n (type: string) =>\n (artwork: Artwork): boolean =>\n isObject(artwork.location) && artwork.location.type === type;\n\n/**\n * Validate one artwork. `edgeNames` is the set of wall-edge names in the scene,\n * used to check `location.edge` references. Returns every problem found, in\n * order, with paths relative to the artwork (the caller scopes them).\n */\nexport const validateArtwork = (artwork: Artwork, edgeNames: Set<string>): ValidationResult =>\n validate<Artwork>([\n existsProp(\"id\", \"Artwork is missing an id.\"),\n existsProp(\"src\", \"Artwork is missing a src.\"),\n existsProp(\"size\", \"Artwork is missing a size.\"),\n propIs(\"size\", isVec2, \"size must be [width, height].\"),\n propIs(\"depth\", isNonNegativeNumber, \"depth must be a non-negative number.\"),\n propIs(\"nativeSrc\", isString, \"nativeSrc must be a string.\"),\n propIs(\"detailDistance\", isPositiveNumber, \"detailDistance must be a positive number.\"),\n propIs(\"year\", isIntegerNumber, \"year must be an integer.\"),\n propIs(\"href\", isString, \"href must be a string.\"),\n propIs(\n \"availability\",\n isOneOf(AVAILABILITY),\n \"availability must be 'for-sale', 'sold' or 'not-for-sale'.\",\n ),\n propIs(\"infoRadius\", isPositiveNumber, \"infoRadius must be a positive number.\"),\n propIs(\"label.side\", isOneOf(LABEL_SIDES), \"side must be 'below', 'left' or 'right'.\"),\n propIs(\"label.fields\", Array.isArray, \"fields must be an array.\"),\n eachPropIs(\"label.fields\", isOneOf(ARTWORK_FIELDS), (field) => `unknown field \"${String(field)}\".`),\n propIs(\"label.scale\", isPositiveNumber, \"scale must be a positive number.\"),\n propIs(\"label.offset\", isVec2, \"offset must be [x, y].\"),\n propIs(\"frame.width\", isFiniteNumber, \"width must be a finite number.\"),\n propIs(\"frame.depth\", isFiniteNumber, \"depth must be a finite number.\"),\n when(\n (a: Artwork) => a.frame?.texture !== undefined,\n existsProp(\"frame.texture.src\", \"frame texture is missing a src.\"),\n ),\n propIs(\n \"frame.passepartout.width\",\n isFiniteNumber,\n \"passepartout width must be a finite number.\",\n ),\n existsProp(\"location\", \"Artwork is missing a location.\"),\n propIs(\"location\", isObject, \"location must be an object.\"),\n when(\n locationOfType(\"wall\"),\n existsProp(\"location.edge\"),\n propIs(\n \"location.edge\",\n (edge) => isString(edge) && edgeNames.has(edge),\n (edge) => `references unknown edge \"${String(edge)}\".`,\n ),\n existsProp(\"location.position\"),\n propIs(\"location.position\", isMeasure, \"position must be a number or a percentage string.\"),\n propIs(\"location.height\", isMeasure, \"height must be a number or a percentage string.\"),\n ),\n when(\n locationOfType(\"floating\"),\n existsProp(\"location.point\"),\n propIs(\"location.point\", isVec3, \"point must be [x, y, z].\"),\n ),\n when(\n (a: Artwork) => isObject(a.location) && !isOneOf(LOCATION_TYPES)(a.location.type),\n fail(\"location.type\", \"unknown location type.\"),\n ),\n ])(artwork);\n\n/**\n * Validate an artwork against its scene: resolves the set of valid wall-edge\n * names from the scene's walls (the wall plugin's data half) so `location.edge`\n * references are checked. Floating pieces don't need any wall.\n */\nexport const validateArtworkInScene = (artwork: Artwork, ctx: SceneContext): ValidationResult => {\n const walls = (ctx.scene as { walls?: Wall[] }).walls ?? [];\n return validateArtwork(artwork, new Set(allEdgeNames(walls)));\n};\n","import { surfaceTextureUrls, type DataPlugin } from \"@scenography/scene\";\n\nimport { validateArtworkInScene } from \"./validate\";\nimport type { Artwork } from \"./artwork\";\n\n/**\n * The data half of the artwork plugin: owns the `artworks` scene key (a\n * collection) and validates each piece — including that a wall-hung piece\n * references a real wall edge. Pure — no three.js.\n */\nexport const artworkData: DataPlugin<Artwork> = {\n kind: \"artworks\",\n cardinality: \"many\",\n validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx),\n assets: (artwork) => [artwork.src, ...surfaceTextureUrls(artwork.frame?.texture)],\n};\n"],"mappings":";;;;AAmFA,MAAa,yBAAkD;CAC7D;CACA;CACA;CACA;AACF;;;AChEA,MAAM,eAA0C,IAAI,IAAkB;CACpE;CACA;CACA;AACF,CAAC;AACD,MAAM,iBAA4C,IAAI,IAAkB;CACtE;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;AACD,MAAM,cAAmC,IAAI,IAAI;CAAC;CAAS;CAAQ;AAAO,CAAC;AAC3E,MAAM,iBAAsC,IAAI,IAAI,CAAC,QAAQ,UAAU,CAAC;AAExE,MAAM,kBACH,UACA,YACC,SAAS,QAAQ,QAAQ,KAAK,QAAQ,SAAS,SAAS;;;;;;AAO5D,MAAa,mBAAmB,SAAkB,cAChD,SAAkB;CAChB,WAAW,MAAM,2BAA2B;CAC5C,WAAW,OAAO,2BAA2B;CAC7C,WAAW,QAAQ,4BAA4B;CAC/C,OAAO,QAAQ,QAAQ,+BAA+B;CACtD,OAAO,SAAS,qBAAqB,sCAAsC;CAC3E,OAAO,aAAa,UAAU,6BAA6B;CAC3D,OAAO,kBAAkB,kBAAkB,2CAA2C;CACtF,OAAO,QAAQ,iBAAiB,0BAA0B;CAC1D,OAAO,QAAQ,UAAU,wBAAwB;CACjD,OACE,gBACA,QAAQ,YAAY,GACpB,4DACF;CACA,OAAO,cAAc,kBAAkB,uCAAuC;CAC9E,OAAO,cAAc,QAAQ,WAAW,GAAG,0CAA0C;CACrF,OAAO,gBAAgB,MAAM,SAAS,0BAA0B;CAChE,WAAW,gBAAgB,QAAQ,cAAc,IAAI,UAAU,kBAAkB,OAAO,KAAK,EAAE,GAAG;CAClG,OAAO,eAAe,kBAAkB,kCAAkC;CAC1E,OAAO,gBAAgB,QAAQ,wBAAwB;CACvD,OAAO,eAAe,gBAAgB,gCAAgC;CACtE,OAAO,eAAe,gBAAgB,gCAAgC;CACtE,MACG,MAAe,EAAE,OAAO,YAAY,KAAA,GACrC,WAAW,qBAAqB,iCAAiC,CACnE;CACA,OACE,4BACA,gBACA,6CACF;CACA,WAAW,YAAY,gCAAgC;CACvD,OAAO,YAAY,UAAU,6BAA6B;CAC1D,KACE,eAAe,MAAM,GACrB,WAAW,eAAe,GAC1B,OACE,kBACC,SAAS,SAAS,IAAI,KAAK,UAAU,IAAI,IAAI,IAC7C,SAAS,4BAA4B,OAAO,IAAI,EAAE,GACrD,GACA,WAAW,mBAAmB,GAC9B,OAAO,qBAAqB,WAAW,mDAAmD,GAC1F,OAAO,mBAAmB,WAAW,iDAAiD,CACxF;CACA,KACE,eAAe,UAAU,GACzB,WAAW,gBAAgB,GAC3B,OAAO,kBAAkB,QAAQ,0BAA0B,CAC7D;CACA,MACG,MAAe,SAAS,EAAE,QAAQ,KAAK,CAAC,QAAQ,cAAc,CAAC,CAAC,EAAE,SAAS,IAAI,GAChF,KAAK,iBAAiB,wBAAwB,CAChD;AACF,CAAC,CAAC,CAAC,OAAO;;;;;;AAOZ,MAAa,0BAA0B,SAAkB,QAAwC;CAC/F,MAAM,QAAS,IAAI,MAA6B,SAAS,CAAC;CAC1D,OAAO,gBAAgB,SAAS,IAAI,IAAI,aAAa,KAAK,CAAC,CAAC;AAC9D;;;;;;;;AC1GA,MAAa,cAAmC;CAC9C,MAAM;CACN,aAAa;CACb,WAAW,SAAS,QAAQ,uBAAuB,SAAS,GAAG;CAC/D,SAAS,YAAY,CAAC,QAAQ,KAAK,GAAG,mBAAmB,QAAQ,OAAO,OAAO,CAAC;AAClF"}
|
|
@@ -41,6 +41,8 @@ const validateArtwork = (artwork, edgeNames) => (0, _scenography_scene.validate)
|
|
|
41
41
|
(0, _scenography_scene.existsProp)("size", "Artwork is missing a size."),
|
|
42
42
|
(0, _scenography_scene.propIs)("size", _scenography_scene.isVec2, "size must be [width, height]."),
|
|
43
43
|
(0, _scenography_scene.propIs)("depth", _scenography_scene.isNonNegativeNumber, "depth must be a non-negative number."),
|
|
44
|
+
(0, _scenography_scene.propIs)("nativeSrc", _scenography_scene.isString, "nativeSrc must be a string."),
|
|
45
|
+
(0, _scenography_scene.propIs)("detailDistance", _scenography_scene.isPositiveNumber, "detailDistance must be a positive number."),
|
|
44
46
|
(0, _scenography_scene.propIs)("year", _scenography_scene.isIntegerNumber, "year must be an integer."),
|
|
45
47
|
(0, _scenography_scene.propIs)("href", _scenography_scene.isString, "href must be a string."),
|
|
46
48
|
(0, _scenography_scene.propIs)("availability", (0, _scenography_scene.isOneOf)(AVAILABILITY), "availability must be 'for-sale', 'sold' or 'not-for-sale'."),
|
|
@@ -79,7 +81,8 @@ const validateArtworkInScene = (artwork, ctx) => {
|
|
|
79
81
|
const artworkData = {
|
|
80
82
|
kind: "artworks",
|
|
81
83
|
cardinality: "many",
|
|
82
|
-
validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx)
|
|
84
|
+
validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx),
|
|
85
|
+
assets: (artwork) => [artwork.src, ...(0, _scenography_scene.surfaceTextureUrls)(artwork.frame?.texture)]
|
|
83
86
|
};
|
|
84
87
|
//#endregion
|
|
85
88
|
Object.defineProperty(exports, "DEFAULT_ARTWORK_FIELDS", {
|
|
@@ -107,4 +110,4 @@ Object.defineProperty(exports, "validateArtworkInScene", {
|
|
|
107
110
|
}
|
|
108
111
|
});
|
|
109
112
|
|
|
110
|
-
//# sourceMappingURL=data-
|
|
113
|
+
//# sourceMappingURL=data-NIfEarkI.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-NIfEarkI.cjs","names":["isVec2","isNonNegativeNumber","isString","isPositiveNumber","isIntegerNumber","isFiniteNumber","isObject","isMeasure","isVec3"],"sources":["../src/data/artwork.ts","../src/data/validate.ts","../src/data/plugin.ts"],"sourcesContent":["import type { Measure, SurfaceTexture, Vec2, Vec3 } from \"@scenography/scene\";\n\n/**\n * Where an artwork is placed. Extensible via `type`; today a piece either hangs\n * on a named wall edge or floats freely. Future element types (floor, ceiling…)\n * add new variants here.\n */\nexport type ArtworkLocation =\n | {\n type: \"wall\";\n /** Name of the `NamedEdge` to hang on. */\n edge: string;\n /** Position along the edge: a number is units from the edge start, `'50%'` is a fraction. */\n position: Measure;\n /**\n * Vertical placement: a number is an absolute world Y, `'50%'` is a\n * fraction of the wall height. Omitted defaults to the camera's eye height.\n */\n height?: Measure;\n /**\n * Which face of the wall the piece shows on, relative to the edge\n * direction. `'right'` (default) is the right-hand side when walking the\n * edge from its start vertex to its end vertex.\n */\n side?: \"right\" | \"left\";\n }\n | {\n type: \"floating\";\n /** World position `[x, y, z]`. */\n point: Vec3;\n /** Y-axis rotation in radians. Defaults to `0`. */\n yaw?: number;\n };\n\n/**\n * A passepartout (mat) sitting between the frame and the image: a flat border\n * that insets the image. Only meaningful when the artwork has a `frame`.\n */\nexport interface Passepartout {\n /** Mat border width around the image, in world units. Defaults to `6`. */\n width?: number;\n /** Mat color (CSS). Defaults to an off-white. */\n color?: string;\n}\n\n/**\n * A frame around an artwork. By default it's four flat bars rendered with a\n * solid `color` or a `texture` (e.g. wood). For an ornate, sculpted frame, point\n * `model` at an external glTF/GLB instead. A `passepartout` mat can sit between\n * the frame and the image.\n */\nexport interface Frame {\n /** Border width (how wide the frame face is), in world units. Defaults to `5`. */\n width?: number;\n /** How far the frame stands proud of the artwork face, in world units. Defaults to `3`. */\n depth?: number;\n /** Frame color (CSS). Defaults to a dark tone. Ignored when `texture` or `model` is set. */\n color?: string;\n /** Surface texture (e.g. wood); takes visual precedence over `color`. */\n texture?: SurfaceTexture;\n /** External glTF/GLB model for an ornate frame; replaces the generated bars. */\n model?: string;\n /** Passepartout mat between the frame and the image. */\n passepartout?: Passepartout;\n}\n\n/**\n * Commercial status of a piece. Gallery convention: a colored dot signals\n * availability. The dot's color is presentation (decided by the renderer), not\n * data — this is the state it derives from.\n */\nexport type Availability = \"for-sale\" | \"sold\" | \"not-for-sale\";\n\n/**\n * The fields a label/info card can show, identified by name. A `fields` array\n * toggles which are visible; the render order is fixed (it is not the array\n * order): title, then author/year, then technique + dimensions on one line, with\n * availability shown as a corner dot. Shared by the physical label (engine) and\n * the overlay info card.\n */\nexport type ArtworkField = \"title\" | \"author\" | \"year\" | \"technique\" | \"dimensions\" | \"availability\";\n\n/** Default visibility when a label/card doesn't specify its own `fields`. */\nexport const DEFAULT_ARTWORK_FIELDS: readonly ArtworkField[] = [\n \"title\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n];\n\n/**\n * A physical wall label (cartela) materialized next to the piece. Its presence\n * is the opt-in; the engine renders it from the artwork's own data.\n */\nexport interface ArtworkLabel {\n /** Where the label sits relative to the piece. Defaults to `'below'`. */\n side?: \"below\" | \"left\" | \"right\";\n /** Which fields to show. Defaults to `DEFAULT_ARTWORK_FIELDS`. */\n fields?: ArtworkField[];\n /**\n * Size multiplier over the real physical size (24pt title etc). Defaults to\n * `1` (true to life), but a real cartela is only a few cm tall — tiny and hard\n * to read/click at gallery scale — so bump this (e.g. `3`) for legibility.\n */\n scale?: number;\n /**\n * Fine-tune the placement with an `[x, y]` shift in world units, added on top\n * of the `side` position (the frame is already cleared). `x` runs along the\n * wall (right is positive), `y` is vertical (up is positive).\n */\n offset?: Vec2;\n}\n\n/** A framed image placed somewhere in the scene. */\nexport interface Artwork {\n id: string;\n location: ArtworkLocation;\n /** Image URL/path to display. */\n src: string;\n /**\n * Full-resolution source for progressive delivery. When present, `src` is a\n * lightweight preview and the plugin swaps this one in when the piece comes\n * within `detailDistance` of the camera.\n */\n nativeSrc?: string;\n /**\n * Distance, in world units, within which `nativeSrc` is loaded. Defaults to\n * 450. Ignored when `nativeSrc` is absent.\n */\n detailDistance?: number;\n /** `[width, height]` of the artwork, in world units. */\n size: Vec2;\n /**\n * Box depth (volume) in world units: the piece is a box with the image on the\n * front and white sides/back. Defaults to `2`.\n */\n depth?: number;\n /** Optional frame around the artwork. */\n frame?: Frame;\n title?: string;\n technique?: string;\n /** Author / artist name. */\n author?: string;\n /** Year the piece was made. */\n year?: number;\n /**\n * Real-world dimensions for display, e.g. `'92 × 73 cm'`. Distinct from `size`,\n * which is in world units.\n */\n dimensions?: string;\n /** Commercial status; drives the availability dot. */\n availability?: Availability;\n /** Long-form caption / catalog entry, shown in the expanded info panel. */\n description?: string;\n /**\n * External URL for the piece (a catalog page, or a marketplace listing when\n * it's for sale). Surfaced as a link in the expanded info panel.\n */\n href?: string;\n /** When present, a physical wall label is materialized next to the piece. */\n label?: ArtworkLabel;\n /**\n * Max distance (world units) from which the piece can be clicked to open its\n * info panel. A gate so far-away pieces aren't interactive. Absent = no gate.\n */\n infoRadius?: number;\n}\n\ndeclare module \"@scenography/scene\" {\n interface Scene {\n /** Framed images placed in the space — provided by `@scenography/plugin-artwork`. */\n artworks?: Artwork[];\n }\n}\n","import {\n eachPropIs,\n existsProp,\n fail,\n isFiniteNumber,\n isIntegerNumber,\n isMeasure,\n isNonNegativeNumber,\n isObject,\n isOneOf,\n isPositiveNumber,\n isString,\n isVec2,\n isVec3,\n propIs,\n validate,\n when,\n type SceneContext,\n type ValidationResult,\n} from \"@scenography/scene\";\nimport { allEdgeNames, type Wall } from \"@scenography/plugin-wall/data\";\n\nimport type { Artwork, ArtworkField, Availability } from \"./artwork\";\n\nconst AVAILABILITY: ReadonlySet<Availability> = new Set<Availability>([\n \"for-sale\",\n \"sold\",\n \"not-for-sale\",\n]);\nconst ARTWORK_FIELDS: ReadonlySet<ArtworkField> = new Set<ArtworkField>([\n \"title\",\n \"author\",\n \"year\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n]);\nconst LABEL_SIDES: ReadonlySet<string> = new Set([\"below\", \"left\", \"right\"]);\nconst LOCATION_TYPES: ReadonlySet<string> = new Set([\"wall\", \"floating\"]);\n\nconst locationOfType =\n (type: string) =>\n (artwork: Artwork): boolean =>\n isObject(artwork.location) && artwork.location.type === type;\n\n/**\n * Validate one artwork. `edgeNames` is the set of wall-edge names in the scene,\n * used to check `location.edge` references. Returns every problem found, in\n * order, with paths relative to the artwork (the caller scopes them).\n */\nexport const validateArtwork = (artwork: Artwork, edgeNames: Set<string>): ValidationResult =>\n validate<Artwork>([\n existsProp(\"id\", \"Artwork is missing an id.\"),\n existsProp(\"src\", \"Artwork is missing a src.\"),\n existsProp(\"size\", \"Artwork is missing a size.\"),\n propIs(\"size\", isVec2, \"size must be [width, height].\"),\n propIs(\"depth\", isNonNegativeNumber, \"depth must be a non-negative number.\"),\n propIs(\"nativeSrc\", isString, \"nativeSrc must be a string.\"),\n propIs(\"detailDistance\", isPositiveNumber, \"detailDistance must be a positive number.\"),\n propIs(\"year\", isIntegerNumber, \"year must be an integer.\"),\n propIs(\"href\", isString, \"href must be a string.\"),\n propIs(\n \"availability\",\n isOneOf(AVAILABILITY),\n \"availability must be 'for-sale', 'sold' or 'not-for-sale'.\",\n ),\n propIs(\"infoRadius\", isPositiveNumber, \"infoRadius must be a positive number.\"),\n propIs(\"label.side\", isOneOf(LABEL_SIDES), \"side must be 'below', 'left' or 'right'.\"),\n propIs(\"label.fields\", Array.isArray, \"fields must be an array.\"),\n eachPropIs(\"label.fields\", isOneOf(ARTWORK_FIELDS), (field) => `unknown field \"${String(field)}\".`),\n propIs(\"label.scale\", isPositiveNumber, \"scale must be a positive number.\"),\n propIs(\"label.offset\", isVec2, \"offset must be [x, y].\"),\n propIs(\"frame.width\", isFiniteNumber, \"width must be a finite number.\"),\n propIs(\"frame.depth\", isFiniteNumber, \"depth must be a finite number.\"),\n when(\n (a: Artwork) => a.frame?.texture !== undefined,\n existsProp(\"frame.texture.src\", \"frame texture is missing a src.\"),\n ),\n propIs(\n \"frame.passepartout.width\",\n isFiniteNumber,\n \"passepartout width must be a finite number.\",\n ),\n existsProp(\"location\", \"Artwork is missing a location.\"),\n propIs(\"location\", isObject, \"location must be an object.\"),\n when(\n locationOfType(\"wall\"),\n existsProp(\"location.edge\"),\n propIs(\n \"location.edge\",\n (edge) => isString(edge) && edgeNames.has(edge),\n (edge) => `references unknown edge \"${String(edge)}\".`,\n ),\n existsProp(\"location.position\"),\n propIs(\"location.position\", isMeasure, \"position must be a number or a percentage string.\"),\n propIs(\"location.height\", isMeasure, \"height must be a number or a percentage string.\"),\n ),\n when(\n locationOfType(\"floating\"),\n existsProp(\"location.point\"),\n propIs(\"location.point\", isVec3, \"point must be [x, y, z].\"),\n ),\n when(\n (a: Artwork) => isObject(a.location) && !isOneOf(LOCATION_TYPES)(a.location.type),\n fail(\"location.type\", \"unknown location type.\"),\n ),\n ])(artwork);\n\n/**\n * Validate an artwork against its scene: resolves the set of valid wall-edge\n * names from the scene's walls (the wall plugin's data half) so `location.edge`\n * references are checked. Floating pieces don't need any wall.\n */\nexport const validateArtworkInScene = (artwork: Artwork, ctx: SceneContext): ValidationResult => {\n const walls = (ctx.scene as { walls?: Wall[] }).walls ?? [];\n return validateArtwork(artwork, new Set(allEdgeNames(walls)));\n};\n","import { surfaceTextureUrls, type DataPlugin } from \"@scenography/scene\";\n\nimport { validateArtworkInScene } from \"./validate\";\nimport type { Artwork } from \"./artwork\";\n\n/**\n * The data half of the artwork plugin: owns the `artworks` scene key (a\n * collection) and validates each piece — including that a wall-hung piece\n * references a real wall edge. Pure — no three.js.\n */\nexport const artworkData: DataPlugin<Artwork> = {\n kind: \"artworks\",\n cardinality: \"many\",\n validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx),\n assets: (artwork) => [artwork.src, ...surfaceTextureUrls(artwork.frame?.texture)],\n};\n"],"mappings":"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"}
|
package/dist/data.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_data = require("./data-
|
|
2
|
+
const require_data = require("./data-NIfEarkI.cjs");
|
|
3
3
|
exports.DEFAULT_ARTWORK_FIELDS = require_data.DEFAULT_ARTWORK_FIELDS;
|
|
4
4
|
exports.artworkData = require_data.artworkData;
|
|
5
5
|
exports.validateArtwork = require_data.validateArtwork;
|
package/dist/data.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, r as validateArtworkInScene, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-
|
|
1
|
+
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, r as validateArtworkInScene, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-Dl6G3iTG.cjs";
|
|
2
2
|
export { type Artwork, type ArtworkField, type ArtworkLabel, type ArtworkLocation, type Availability, DEFAULT_ARTWORK_FIELDS, type Frame, type Passepartout, artworkData, validateArtwork, validateArtworkInScene };
|
package/dist/data.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, r as validateArtworkInScene, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-
|
|
1
|
+
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, r as validateArtworkInScene, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-Dl6G3iTG.mjs";
|
|
2
2
|
export { type Artwork, type ArtworkField, type ArtworkLabel, type ArtworkLocation, type Availability, DEFAULT_ARTWORK_FIELDS, type Frame, type Passepartout, artworkData, validateArtwork, validateArtworkInScene };
|
package/dist/data.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, r as validateArtworkInScene, t as artworkData } from "./data-
|
|
1
|
+
import { i as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, r as validateArtworkInScene, t as artworkData } from "./data-D1hNczp3.mjs";
|
|
2
2
|
export { DEFAULT_ARTWORK_FIELDS, artworkData, validateArtwork, validateArtworkInScene };
|
|
@@ -100,6 +100,17 @@ interface Artwork {
|
|
|
100
100
|
location: ArtworkLocation;
|
|
101
101
|
/** Image URL/path to display. */
|
|
102
102
|
src: string;
|
|
103
|
+
/**
|
|
104
|
+
* Full-resolution source for progressive delivery. When present, `src` is a
|
|
105
|
+
* lightweight preview and the plugin swaps this one in when the piece comes
|
|
106
|
+
* within `detailDistance` of the camera.
|
|
107
|
+
*/
|
|
108
|
+
nativeSrc?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Distance, in world units, within which `nativeSrc` is loaded. Defaults to
|
|
111
|
+
* 450. Ignored when `nativeSrc` is absent.
|
|
112
|
+
*/
|
|
113
|
+
detailDistance?: number;
|
|
103
114
|
/** `[width, height]` of the artwork, in world units. */
|
|
104
115
|
size: Vec2;
|
|
105
116
|
/**
|
|
@@ -167,4 +178,4 @@ declare const validateArtworkInScene: (artwork: Artwork, ctx: SceneContext) => V
|
|
|
167
178
|
declare const artworkData: DataPlugin<Artwork>;
|
|
168
179
|
//#endregion
|
|
169
180
|
export { ArtworkField as a, Availability as c, Passepartout as d, Artwork as i, DEFAULT_ARTWORK_FIELDS as l, validateArtwork as n, ArtworkLabel as o, validateArtworkInScene as r, ArtworkLocation as s, artworkData as t, Frame as u };
|
|
170
|
-
//# sourceMappingURL=index-
|
|
181
|
+
//# sourceMappingURL=index-Dl6G3iTG.d.cts.map
|
|
@@ -100,6 +100,17 @@ interface Artwork {
|
|
|
100
100
|
location: ArtworkLocation;
|
|
101
101
|
/** Image URL/path to display. */
|
|
102
102
|
src: string;
|
|
103
|
+
/**
|
|
104
|
+
* Full-resolution source for progressive delivery. When present, `src` is a
|
|
105
|
+
* lightweight preview and the plugin swaps this one in when the piece comes
|
|
106
|
+
* within `detailDistance` of the camera.
|
|
107
|
+
*/
|
|
108
|
+
nativeSrc?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Distance, in world units, within which `nativeSrc` is loaded. Defaults to
|
|
111
|
+
* 450. Ignored when `nativeSrc` is absent.
|
|
112
|
+
*/
|
|
113
|
+
detailDistance?: number;
|
|
103
114
|
/** `[width, height]` of the artwork, in world units. */
|
|
104
115
|
size: Vec2;
|
|
105
116
|
/**
|
|
@@ -167,4 +178,4 @@ declare const validateArtworkInScene: (artwork: Artwork, ctx: SceneContext) => V
|
|
|
167
178
|
declare const artworkData: DataPlugin<Artwork>;
|
|
168
179
|
//#endregion
|
|
169
180
|
export { ArtworkField as a, Availability as c, Passepartout as d, Artwork as i, DEFAULT_ARTWORK_FIELDS as l, validateArtwork as n, ArtworkLabel as o, validateArtworkInScene as r, ArtworkLocation as s, artworkData as t, Frame as u };
|
|
170
|
-
//# sourceMappingURL=index-
|
|
181
|
+
//# sourceMappingURL=index-Dl6G3iTG.d.mts.map
|
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_data = require("./data-
|
|
3
|
-
let _scenography_engine = require("@scenography/engine");
|
|
2
|
+
const require_data = require("./data-NIfEarkI.cjs");
|
|
4
3
|
let three = require("three");
|
|
4
|
+
let _scenography_engine = require("@scenography/engine");
|
|
5
5
|
//#region src/buildLabel.ts
|
|
6
6
|
const MM_PER_PT = .3528;
|
|
7
7
|
const UNITS_PER_MM = .1;
|
|
@@ -269,7 +269,7 @@ function buildPiece(artwork) {
|
|
|
269
269
|
imageWidth = Math.max(width - 2 * mw, 1);
|
|
270
270
|
imageHeight = Math.max(height - 2 * mw, 1);
|
|
271
271
|
}
|
|
272
|
-
const texture = loader.load(artwork.src);
|
|
272
|
+
const texture = loader.load(artwork.src, () => (0, _scenography_engine.trackAsset)(artwork.src));
|
|
273
273
|
texture.colorSpace = three.SRGBColorSpace;
|
|
274
274
|
const image = new three.Mesh(new three.PlaneGeometry(imageWidth, imageHeight), new three.MeshStandardMaterial({
|
|
275
275
|
map: texture,
|
|
@@ -277,6 +277,7 @@ function buildPiece(artwork) {
|
|
|
277
277
|
}));
|
|
278
278
|
image.position.z = front + IMAGE_GAP;
|
|
279
279
|
image.receiveShadow = true;
|
|
280
|
+
image.name = "artwork-image";
|
|
280
281
|
group.add(image);
|
|
281
282
|
if (frame) if (frame.model) loadFrameModel(group, frame.model, front);
|
|
282
283
|
else group.add(buildFrameBars(frame, width, height, front));
|
|
@@ -334,20 +335,101 @@ function buildArtwork(artwork, edges, eyeHeight) {
|
|
|
334
335
|
//#region src/plugin.ts
|
|
335
336
|
const EMPTY_EDGES = /* @__PURE__ */ new Map();
|
|
336
337
|
const DEFAULT_EYE_HEIGHT = 150;
|
|
338
|
+
const DEFAULT_DETAIL_DISTANCE = 450;
|
|
339
|
+
/** Full-resolution textures kept on the GPU at once. */
|
|
340
|
+
const KEEP = 3;
|
|
341
|
+
/** How often the camera distance is checked, in ms. */
|
|
342
|
+
const TICK = 400;
|
|
337
343
|
/**
|
|
338
344
|
* The artwork plugin: materializes each `Artwork` as a framed box (with an
|
|
339
345
|
* optional physical label), placed on a wall edge or floating. Consumes the
|
|
340
346
|
* `edges` capability (from the wall plugin) to resolve wall placements and
|
|
341
347
|
* `eyeHeight` for the default hang height, rebuilding when either changes.
|
|
348
|
+
*
|
|
349
|
+
* When an artwork carries `nativeSrc`, its `src` is treated as a preview: the
|
|
350
|
+
* full texture loads as the visitor comes within `detailDistance`, keeping at
|
|
351
|
+
* most `KEEP` of them on the GPU.
|
|
342
352
|
*/
|
|
343
|
-
const artwork = () =>
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
353
|
+
const artwork = () => {
|
|
354
|
+
const entries = /* @__PURE__ */ new Map();
|
|
355
|
+
const loaded = [];
|
|
356
|
+
const loader = new three.TextureLoader();
|
|
357
|
+
const scratch = new three.Vector3();
|
|
358
|
+
const drop = (id) => {
|
|
359
|
+
const entry = entries.get(id);
|
|
360
|
+
if (!entry?.native) return;
|
|
361
|
+
entry.material.map = entry.preview;
|
|
362
|
+
entry.material.needsUpdate = true;
|
|
363
|
+
entry.native.dispose();
|
|
364
|
+
entry.native = void 0;
|
|
365
|
+
const index = loaded.indexOf(id);
|
|
366
|
+
if (index !== -1) loaded.splice(index, 1);
|
|
367
|
+
};
|
|
368
|
+
const touch = (id) => {
|
|
369
|
+
const index = loaded.indexOf(id);
|
|
370
|
+
if (index !== -1) loaded.splice(index, 1);
|
|
371
|
+
loaded.push(id);
|
|
372
|
+
while (loaded.length > KEEP) {
|
|
373
|
+
const evicted = loaded[0];
|
|
374
|
+
if (evicted === void 0) break;
|
|
375
|
+
drop(evicted);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
const load = (entry) => {
|
|
379
|
+
if (entry.native) return;
|
|
380
|
+
const texture = loader.load(entry.nativeSrc, () => {
|
|
381
|
+
if (entries.get(entry.id)?.native !== texture) return;
|
|
382
|
+
entry.material.map = texture;
|
|
383
|
+
entry.material.needsUpdate = true;
|
|
384
|
+
});
|
|
385
|
+
texture.colorSpace = three.SRGBColorSpace;
|
|
386
|
+
entry.native = texture;
|
|
387
|
+
touch(entry.id);
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
data: require_data.artworkData,
|
|
391
|
+
dependsOn: [_scenography_engine.EDGES, _scenography_engine.EYE_HEIGHT],
|
|
392
|
+
build: (definition, ctx) => {
|
|
393
|
+
const object = buildArtwork(definition, ctx.capabilities.get(_scenography_engine.EDGES) ?? EMPTY_EDGES, ctx.capabilities.get(_scenography_engine.EYE_HEIGHT) ?? DEFAULT_EYE_HEIGHT);
|
|
394
|
+
if (!object) return {};
|
|
395
|
+
if (!definition.nativeSrc) return { object };
|
|
396
|
+
const image = object.getObjectByName("artwork-image");
|
|
397
|
+
const preview = image?.material?.map;
|
|
398
|
+
if (!image || !preview) return { object };
|
|
399
|
+
const entry = {
|
|
400
|
+
id: definition.id,
|
|
401
|
+
object,
|
|
402
|
+
material: image.material,
|
|
403
|
+
preview,
|
|
404
|
+
nativeSrc: definition.nativeSrc
|
|
405
|
+
};
|
|
406
|
+
entries.set(entry.id, entry);
|
|
407
|
+
const distance = definition.detailDistance ?? DEFAULT_DETAIL_DISTANCE;
|
|
408
|
+
let elapsed = 0;
|
|
409
|
+
return {
|
|
410
|
+
object,
|
|
411
|
+
update: (deltaMs) => {
|
|
412
|
+
elapsed += deltaMs;
|
|
413
|
+
if (elapsed < TICK) return;
|
|
414
|
+
elapsed = 0;
|
|
415
|
+
object.getWorldPosition(scratch);
|
|
416
|
+
const dx = ctx.camera.position.x - scratch.x;
|
|
417
|
+
const dz = ctx.camera.position.z - scratch.z;
|
|
418
|
+
if (dx * dx + dz * dz > distance * distance) return;
|
|
419
|
+
if (entry.native) touch(entry.id);
|
|
420
|
+
else load(entry);
|
|
421
|
+
},
|
|
422
|
+
dispose: () => {
|
|
423
|
+
entries.delete(entry.id);
|
|
424
|
+
const index = loaded.indexOf(entry.id);
|
|
425
|
+
if (index !== -1) loaded.splice(index, 1);
|
|
426
|
+
entry.native?.dispose();
|
|
427
|
+
entry.native = void 0;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
};
|
|
351
433
|
//#endregion
|
|
352
434
|
exports.DEFAULT_ARTWORK_FIELDS = require_data.DEFAULT_ARTWORK_FIELDS;
|
|
353
435
|
exports.artwork = artwork;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["DEFAULT_ARTWORK_FIELDS","CanvasTexture","SRGBColorSpace","LinearFilter","Mesh","PlaneGeometry","MeshBasicMaterial","DEFAULT_DEPTH","TextureLoader","MeshStandardMaterial","Group","BoxGeometry","Mesh","PlaneGeometry","SRGBColorSpace","artworkData","EDGES","EYE_HEIGHT"],"sources":["../src/buildLabel.ts","../src/placement.ts","../src/buildArtwork.ts","../src/plugin.ts"],"sourcesContent":["import {\n CanvasTexture,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n type Object3D,\n} from \"three\";\nimport { DEFAULT_ARTWORK_FIELDS, type Artwork, type ArtworkField } from \"./data\";\n\n// World units are centimeters (100 units = 1 m), so the label is sized to real\n// physical dimensions. Text is rasterized to a canvas (zero assets shipped); the\n// canvas resolution (px per world unit) only affects crispness, not size.\nconst MM_PER_PT = 0.3528;\nconst UNITS_PER_MM = 0.1; // 1 unit = 1 cm = 10 mm\nconst PX_PER_UNIT = 48; // canvas resolution\n\nconst ptToUnits = (pt: number): number => pt * MM_PER_PT * UNITS_PER_MM;\nconst ptToPx = (pt: number): number => ptToUnits(pt) * PX_PER_UNIT;\n\nconst TITLE_PT = 24;\nconst BODY_PT = 18;\nconst DOT_DIAMETER_UNITS = 0.5; // 5 mm\nconst PAD_PX = 4 * UNITS_PER_MM * PX_PER_UNIT; // ~4 mm padding\nconst LINE_GAP_PX = 2 * UNITS_PER_MM * PX_PER_UNIT; // ~2 mm between lines\n\n/** Gap between the piece and its label, in world units (cm). */\nexport const LABEL_GAP = 3;\n\nconst AVAILABILITY_COLOR: Record<NonNullable<Artwork[\"availability\"]>, string> = {\n \"for-sale\": \"#22c55e\",\n sold: \"#ef4444\",\n \"not-for-sale\": \"#ef4444\",\n};\n\nconst has = (fields: readonly ArtworkField[], field: ArtworkField): boolean =>\n fields.includes(field);\nconst joinParts = (parts: (string | undefined)[], sep: string): string =>\n parts.filter((p): p is string => Boolean(p)).join(sep);\n\n/** A built physical label plus its real-world size (for placement/picking). */\nexport interface BuiltLabel {\n object: Object3D;\n worldWidth: number;\n worldHeight: number;\n}\n\n/**\n * Build a physical wall label (cartela) for an artwork, rendered to a canvas\n * texture at real physical size: a bold 24pt title, 18pt body, and a 5 mm\n * availability dot in the top-right corner. Field visibility follows\n * `artwork.label.fields` (default `DEFAULT_ARTWORK_FIELDS`); render order is fixed\n * regardless of array order. Returns `null` when nothing is visible.\n */\nexport function buildLabel(artwork: Artwork): BuiltLabel | null {\n const fields = artwork.label?.fields ?? DEFAULT_ARTWORK_FIELDS;\n\n const lines: { text: string; px: number; bold: boolean }[] = [];\n if (has(fields, \"title\") && artwork.title)\n lines.push({ text: artwork.title, px: ptToPx(TITLE_PT), bold: true });\n const authorYear = joinParts(\n [\n has(fields, \"author\") ? artwork.author : undefined,\n has(fields, \"year\") ? artwork.year?.toString() : undefined,\n ],\n \", \",\n );\n if (authorYear) lines.push({ text: authorYear, px: ptToPx(BODY_PT), bold: false });\n const techDims = joinParts(\n [\n has(fields, \"technique\") ? artwork.technique : undefined,\n has(fields, \"dimensions\") ? artwork.dimensions : undefined,\n ],\n \", \",\n );\n if (techDims) lines.push({ text: techDims, px: ptToPx(BODY_PT), bold: false });\n\n const showDot = has(fields, \"availability\") && artwork.availability !== undefined;\n if (lines.length === 0 && !showDot) return null;\n\n const canvas = document.createElement(\"canvas\");\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return null;\n\n const font = (line: { px: number; bold: boolean }): string =>\n `${line.bold ? \"700\" : \"400\"} ${line.px}px system-ui, sans-serif`;\n\n // Width from the widest line (plus room for the dot on the first line), height\n // from the stacked lines — so the card is exactly as big as its content.\n const dotPx = DOT_DIAMETER_UNITS * PX_PER_UNIT;\n let maxTextWidth = 0;\n for (const line of lines) {\n ctx.font = font(line);\n maxTextWidth = Math.max(maxTextWidth, ctx.measureText(line.text).width);\n }\n const dotReserve = showDot ? dotPx + LINE_GAP_PX : 0;\n canvas.width = Math.ceil(maxTextWidth + dotReserve + 2 * PAD_PX);\n const contentHeight = lines.reduce((sum, l) => sum + l.px + LINE_GAP_PX, 0);\n canvas.height = Math.ceil(Math.max(contentHeight, dotPx) + 2 * PAD_PX);\n\n ctx.fillStyle = \"#f7f5f0\";\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n ctx.textBaseline = \"top\";\n ctx.fillStyle = \"#1c1917\";\n let y = PAD_PX;\n for (const line of lines) {\n ctx.font = font(line);\n ctx.fillText(line.text, PAD_PX, y);\n y += line.px + LINE_GAP_PX;\n }\n\n if (showDot) {\n ctx.beginPath();\n ctx.arc(canvas.width - PAD_PX - dotPx / 2, PAD_PX + dotPx / 2, dotPx / 2, 0, Math.PI * 2);\n ctx.fillStyle = AVAILABILITY_COLOR[artwork.availability as NonNullable<Artwork[\"availability\"]>];\n ctx.fill();\n }\n\n const texture = new CanvasTexture(canvas);\n texture.colorSpace = SRGBColorSpace;\n texture.minFilter = LinearFilter;\n texture.magFilter = LinearFilter;\n\n // Real physical size, optionally scaled up for legibility (the canvas\n // resolution is unchanged, so the text just renders larger on the plane).\n const scale = artwork.label?.scale ?? 1;\n const worldWidth = (canvas.width / PX_PER_UNIT) * scale;\n const worldHeight = (canvas.height / PX_PER_UNIT) * scale;\n const mesh = new Mesh(\n new PlaneGeometry(worldWidth, worldHeight),\n new MeshBasicMaterial({ map: texture, transparent: true }),\n );\n mesh.name = `label:${artwork.id}`;\n return { object: mesh, worldWidth, worldHeight };\n}\n","import type { EdgeIndex } from \"@scenography/engine\";\nimport type { Measure } from \"@scenography/scene\";\n\nimport type { Artwork } from \"./data\";\n\nconst SURFACE_GAP = 1;\nconst DEFAULT_DEPTH = 2;\n\nconst clamp01 = (t: number): number => Math.min(Math.max(t, 0), 1);\n\nconst parseMeasure = (m: Measure): { value: number; percent: boolean } =>\n typeof m === \"number\" ? { value: m, percent: false } : { value: parseFloat(m), percent: true };\n\n/** Where an artwork sits: world position `[x, y, z]` and Y-axis rotation. */\nexport interface Placement {\n position: [number, number, number];\n yaw: number;\n}\n\n/**\n * Resolve an artwork's placement from its location, the resolved wall edges and\n * the default eye height. Pure geometry — no three.js. Returns `null` for a\n * wall piece whose edge is not (yet) in the index, so the caller can skip it.\n */\nexport function placeArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Placement | null {\n const loc = artwork.location;\n if (loc.type === \"floating\") {\n return { position: [loc.point[0], loc.point[1], loc.point[2]], yaw: loc.yaw ?? 0 };\n }\n\n const edge = edges.get(loc.edge);\n if (!edge) return null;\n\n const [ax, az] = edge.a;\n const [bx, bz] = edge.b;\n const dx = bx - ax;\n const dz = bz - az;\n const length = Math.hypot(dx, dz) || 1;\n const ux = dx / length;\n const uz = dz / length;\n\n // Position along the edge.\n const pos = parseMeasure(loc.position);\n const t = clamp01(pos.percent ? pos.value / 100 : pos.value / length);\n const px = ax + dx * t;\n const pz = az + dz * t;\n\n // Vertical placement: eye height by default, or an explicit absolute/percent.\n let py = eyeHeight;\n if (loc.height !== undefined) {\n const h = parseMeasure(loc.height);\n py = h.percent ? (h.value / 100) * edge.height : h.value;\n }\n\n // Outward normal: right-hand side of the edge by default. Offset by the box\n // half-depth so its back rests against the wall surface.\n const sign = loc.side === \"left\" ? -1 : 1;\n const nx = uz * sign;\n const nz = -ux * sign;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const offset = edge.thickness / 2 + SURFACE_GAP + depth / 2;\n\n return {\n position: [px + nx * offset, py, pz + nz * offset],\n yaw: Math.atan2(nx, nz),\n };\n}\n","import {\n BoxGeometry,\n Group,\n Mesh,\n MeshStandardMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n TextureLoader,\n type Object3D,\n} from \"three\";\nimport { applyTexture, type EdgeIndex } from \"@scenography/engine\";\n\nimport type { Artwork, Frame } from \"./data\";\nimport { buildLabel, LABEL_GAP } from \"./buildLabel\";\nimport { placeArtwork } from \"./placement\";\n\nconst loader = new TextureLoader();\n\nconst DEFAULT_DEPTH = 2;\nconst DEFAULT_FRAME_WIDTH = 5;\nconst DEFAULT_FRAME_DEPTH = 3;\nconst DEFAULT_FRAME_COLOR = \"#2e2622\";\nconst DEFAULT_MAT_WIDTH = 6;\nconst DEFAULT_MAT_COLOR = \"#f4f0e8\";\n\n// Tiny z-offsets stacked on the box front to avoid z-fighting.\nconst MAT_GAP = 0.2;\nconst IMAGE_GAP = 0.4;\n\nconst frameMaterial = (frame: Frame): { material: MeshStandardMaterial; needsUv1: boolean } => {\n const material = new MeshStandardMaterial({\n color: frame.color ?? DEFAULT_FRAME_COLOR,\n roughness: 0.7,\n });\n const needsUv1 = frame.texture ? applyTexture(material, frame.texture) : false;\n return { material, needsUv1 };\n};\n\n/** Four flat bars forming a border around the front face, standing proud of it. */\nfunction buildFrameBars(frame: Frame, width: number, height: number, front: number): Group {\n const fw = frame.width ?? DEFAULT_FRAME_WIDTH;\n const fd = frame.depth ?? DEFAULT_FRAME_DEPTH;\n const { material, needsUv1 } = frameMaterial(frame);\n const group = new Group();\n const z = front + fd / 2;\n\n // Horizontals span the full outer width (covering the corners); verticals fill\n // the gap between them (butt joints).\n const bars: [number, number, number, number][] = [\n [width + 2 * fw, fw, 0, height / 2 + fw / 2], // top\n [width + 2 * fw, fw, 0, -(height / 2 + fw / 2)], // bottom\n [fw, height, -(width / 2 + fw / 2), 0], // left\n [fw, height, width / 2 + fw / 2, 0], // right\n ];\n for (const [bw, bh, x, y] of bars) {\n const geometry = new BoxGeometry(bw, bh, fd);\n if (needsUv1) {\n const uv = geometry.getAttribute(\"uv\");\n if (uv) geometry.setAttribute(\"uv1\", uv);\n }\n const bar = new Mesh(geometry, material);\n bar.position.set(x, y, z);\n bar.castShadow = true;\n bar.receiveShadow = true;\n group.add(bar);\n }\n return group;\n}\n\n/** Load an ornate frame from an external glTF/GLB, placed at the front face. */\nfunction loadFrameModel(group: Group, src: string, front: number): void {\n void import(\"three/examples/jsm/loaders/GLTFLoader.js\").then(({ GLTFLoader }) => {\n new GLTFLoader().load(src, (gltf) => {\n gltf.scene.position.z = front;\n gltf.scene.traverse((child) => {\n child.castShadow = true;\n child.receiveShadow = true;\n });\n group.add(gltf.scene);\n });\n });\n}\n\n/**\n * Build the artwork piece: a box (the image on the front, white sides and back),\n * an optional passepartout mat, and an optional frame (generated bars or an\n * external model). Shared by every location type.\n */\nfunction buildPiece(artwork: Artwork): Group {\n const [width, height] = artwork.size;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const front = depth / 2;\n const group = new Group();\n\n const body = new Mesh(\n new BoxGeometry(width, height, depth),\n new MeshStandardMaterial({ color: \"#ffffff\", roughness: 0.9 }),\n );\n body.castShadow = true;\n body.receiveShadow = true;\n group.add(body);\n\n // Passepartout only applies when there's a frame.\n const frame = artwork.frame;\n const mat = frame?.passepartout;\n let imageWidth = width;\n let imageHeight = height;\n if (mat) {\n const mw = mat.width ?? DEFAULT_MAT_WIDTH;\n const matMesh = new Mesh(\n new PlaneGeometry(width, height),\n new MeshStandardMaterial({ color: mat.color ?? DEFAULT_MAT_COLOR, roughness: 0.95 }),\n );\n matMesh.position.z = front + MAT_GAP;\n matMesh.receiveShadow = true;\n group.add(matMesh);\n imageWidth = Math.max(width - 2 * mw, 1);\n imageHeight = Math.max(height - 2 * mw, 1);\n }\n\n const texture = loader.load(artwork.src);\n texture.colorSpace = SRGBColorSpace;\n const image = new Mesh(\n new PlaneGeometry(imageWidth, imageHeight),\n new MeshStandardMaterial({ map: texture, roughness: 0.9 }),\n );\n image.position.z = front + IMAGE_GAP;\n image.receiveShadow = true;\n group.add(image);\n\n if (frame) {\n if (frame.model) loadFrameModel(group, frame.model, front);\n else group.add(buildFrameBars(frame, width, height, front));\n }\n\n return group;\n}\n\n/**\n * Build the three.js object for an `Artwork`, placing it according to its\n * `location`. Wall-hung pieces are positioned along a resolved edge (the box's\n * back resting on the wall); floating pieces use explicit coordinates. Returns\n * `null` if a wall location references an edge that no longer exists.\n */\nexport function buildArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Object3D | null {\n const group = buildPiece(artwork);\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const [pieceWidth, pieceHeight] = artwork.size;\n const front = depth / 2;\n\n // Physical wall label (cartela) next to the piece, when declared. Placed near\n // the wall surface (the box's back), not at the protruding front face. When a\n // label exists it's the click target; otherwise the piece itself is (set below).\n const label = artwork.label ? buildLabel(artwork) : null;\n if (label) {\n const z = -depth / 2 + 0.5;\n const side = artwork.label?.side ?? \"below\";\n // Clear the frame so the cartela isn't tucked under the (proud) frame bars.\n const frameExtent = artwork.frame ? (artwork.frame.width ?? DEFAULT_FRAME_WIDTH) : 0;\n const [ox, oy] = artwork.label?.offset ?? [0, 0];\n if (side === \"below\") {\n const y = -(pieceHeight / 2 + frameExtent + LABEL_GAP + label.worldHeight / 2);\n label.object.position.set(ox, y + oy, z);\n } else {\n const sign = side === \"left\" ? -1 : 1;\n const x = sign * (pieceWidth / 2 + frameExtent + LABEL_GAP + label.worldWidth / 2);\n label.object.position.set(x + ox, oy, z);\n }\n // The label mesh is the pick target; mark only it (not the group) so the\n // piece stays non-interactive when a label is present.\n label.object.userData.pickTarget = artwork.id;\n label.object.userData.pickInfo = { width: label.worldWidth, height: label.worldHeight, frontOffset: 0.2 };\n group.add(label.object);\n } else {\n // No label: the piece is the selector. Mark the group so any of its meshes\n // resolve to this id, and outline the front image face on hover.\n group.userData.pickTarget = artwork.id;\n group.userData.pickInfo = { width: pieceWidth, height: pieceHeight, frontOffset: front + 0.6 };\n }\n\n const placement = placeArtwork(artwork, edges, eyeHeight);\n if (!placement) return null;\n group.position.set(...placement.position);\n group.rotation.y = placement.yaw;\n\n group.name = `artwork:${artwork.id}`;\n group.userData.scenographyId = artwork.id;\n // Real-world face size, consumed by the camera framing to fit the piece.\n group.userData.size = artwork.size;\n return group;\n}\n","import { EDGES, EYE_HEIGHT, type EdgeIndex, type EnginePlugin } from \"@scenography/engine\";\n\nimport { buildArtwork } from \"./buildArtwork\";\nimport { artworkData, type Artwork } from \"./data\";\n\nconst EMPTY_EDGES: EdgeIndex = new Map();\nconst DEFAULT_EYE_HEIGHT = 150;\n\n/**\n * The artwork plugin: materializes each `Artwork` as a framed box (with an\n * optional physical label), placed on a wall edge or floating. Consumes the\n * `edges` capability (from the wall plugin) to resolve wall placements and\n * `eyeHeight` for the default hang height, rebuilding when either changes.\n */\nexport const artwork = (): EnginePlugin<Artwork> => ({\n data: artworkData,\n dependsOn: [EDGES, EYE_HEIGHT],\n build: (definition, ctx) => {\n const edges = ctx.capabilities.get<EdgeIndex>(EDGES) ?? EMPTY_EDGES;\n const eyeHeight = ctx.capabilities.get<number>(EYE_HEIGHT) ?? DEFAULT_EYE_HEIGHT;\n const object = buildArtwork(definition, edges, eyeHeight);\n return object ? { object } : {};\n },\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["DEFAULT_ARTWORK_FIELDS","CanvasTexture","SRGBColorSpace","LinearFilter","Mesh","PlaneGeometry","MeshBasicMaterial","DEFAULT_DEPTH","TextureLoader","MeshStandardMaterial","Group","BoxGeometry","Mesh","PlaneGeometry","SRGBColorSpace","TextureLoader","Vector3","SRGBColorSpace","artworkData","EDGES","EYE_HEIGHT"],"sources":["../src/buildLabel.ts","../src/placement.ts","../src/buildArtwork.ts","../src/plugin.ts"],"sourcesContent":["import {\n CanvasTexture,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n type Object3D,\n} from \"three\";\nimport { DEFAULT_ARTWORK_FIELDS, type Artwork, type ArtworkField } from \"./data\";\n\n// World units are centimeters (100 units = 1 m), so the label is sized to real\n// physical dimensions. Text is rasterized to a canvas (zero assets shipped); the\n// canvas resolution (px per world unit) only affects crispness, not size.\nconst MM_PER_PT = 0.3528;\nconst UNITS_PER_MM = 0.1; // 1 unit = 1 cm = 10 mm\nconst PX_PER_UNIT = 48; // canvas resolution\n\nconst ptToUnits = (pt: number): number => pt * MM_PER_PT * UNITS_PER_MM;\nconst ptToPx = (pt: number): number => ptToUnits(pt) * PX_PER_UNIT;\n\nconst TITLE_PT = 24;\nconst BODY_PT = 18;\nconst DOT_DIAMETER_UNITS = 0.5; // 5 mm\nconst PAD_PX = 4 * UNITS_PER_MM * PX_PER_UNIT; // ~4 mm padding\nconst LINE_GAP_PX = 2 * UNITS_PER_MM * PX_PER_UNIT; // ~2 mm between lines\n\n/** Gap between the piece and its label, in world units (cm). */\nexport const LABEL_GAP = 3;\n\nconst AVAILABILITY_COLOR: Record<NonNullable<Artwork[\"availability\"]>, string> = {\n \"for-sale\": \"#22c55e\",\n sold: \"#ef4444\",\n \"not-for-sale\": \"#ef4444\",\n};\n\nconst has = (fields: readonly ArtworkField[], field: ArtworkField): boolean =>\n fields.includes(field);\nconst joinParts = (parts: (string | undefined)[], sep: string): string =>\n parts.filter((p): p is string => Boolean(p)).join(sep);\n\n/** A built physical label plus its real-world size (for placement/picking). */\nexport interface BuiltLabel {\n object: Object3D;\n worldWidth: number;\n worldHeight: number;\n}\n\n/**\n * Build a physical wall label (cartela) for an artwork, rendered to a canvas\n * texture at real physical size: a bold 24pt title, 18pt body, and a 5 mm\n * availability dot in the top-right corner. Field visibility follows\n * `artwork.label.fields` (default `DEFAULT_ARTWORK_FIELDS`); render order is fixed\n * regardless of array order. Returns `null` when nothing is visible.\n */\nexport function buildLabel(artwork: Artwork): BuiltLabel | null {\n const fields = artwork.label?.fields ?? DEFAULT_ARTWORK_FIELDS;\n\n const lines: { text: string; px: number; bold: boolean }[] = [];\n if (has(fields, \"title\") && artwork.title)\n lines.push({ text: artwork.title, px: ptToPx(TITLE_PT), bold: true });\n const authorYear = joinParts(\n [\n has(fields, \"author\") ? artwork.author : undefined,\n has(fields, \"year\") ? artwork.year?.toString() : undefined,\n ],\n \", \",\n );\n if (authorYear) lines.push({ text: authorYear, px: ptToPx(BODY_PT), bold: false });\n const techDims = joinParts(\n [\n has(fields, \"technique\") ? artwork.technique : undefined,\n has(fields, \"dimensions\") ? artwork.dimensions : undefined,\n ],\n \", \",\n );\n if (techDims) lines.push({ text: techDims, px: ptToPx(BODY_PT), bold: false });\n\n const showDot = has(fields, \"availability\") && artwork.availability !== undefined;\n if (lines.length === 0 && !showDot) return null;\n\n const canvas = document.createElement(\"canvas\");\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return null;\n\n const font = (line: { px: number; bold: boolean }): string =>\n `${line.bold ? \"700\" : \"400\"} ${line.px}px system-ui, sans-serif`;\n\n // Width from the widest line (plus room for the dot on the first line), height\n // from the stacked lines — so the card is exactly as big as its content.\n const dotPx = DOT_DIAMETER_UNITS * PX_PER_UNIT;\n let maxTextWidth = 0;\n for (const line of lines) {\n ctx.font = font(line);\n maxTextWidth = Math.max(maxTextWidth, ctx.measureText(line.text).width);\n }\n const dotReserve = showDot ? dotPx + LINE_GAP_PX : 0;\n canvas.width = Math.ceil(maxTextWidth + dotReserve + 2 * PAD_PX);\n const contentHeight = lines.reduce((sum, l) => sum + l.px + LINE_GAP_PX, 0);\n canvas.height = Math.ceil(Math.max(contentHeight, dotPx) + 2 * PAD_PX);\n\n ctx.fillStyle = \"#f7f5f0\";\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n ctx.textBaseline = \"top\";\n ctx.fillStyle = \"#1c1917\";\n let y = PAD_PX;\n for (const line of lines) {\n ctx.font = font(line);\n ctx.fillText(line.text, PAD_PX, y);\n y += line.px + LINE_GAP_PX;\n }\n\n if (showDot) {\n ctx.beginPath();\n ctx.arc(canvas.width - PAD_PX - dotPx / 2, PAD_PX + dotPx / 2, dotPx / 2, 0, Math.PI * 2);\n ctx.fillStyle = AVAILABILITY_COLOR[artwork.availability as NonNullable<Artwork[\"availability\"]>];\n ctx.fill();\n }\n\n const texture = new CanvasTexture(canvas);\n texture.colorSpace = SRGBColorSpace;\n texture.minFilter = LinearFilter;\n texture.magFilter = LinearFilter;\n\n // Real physical size, optionally scaled up for legibility (the canvas\n // resolution is unchanged, so the text just renders larger on the plane).\n const scale = artwork.label?.scale ?? 1;\n const worldWidth = (canvas.width / PX_PER_UNIT) * scale;\n const worldHeight = (canvas.height / PX_PER_UNIT) * scale;\n const mesh = new Mesh(\n new PlaneGeometry(worldWidth, worldHeight),\n new MeshBasicMaterial({ map: texture, transparent: true }),\n );\n mesh.name = `label:${artwork.id}`;\n return { object: mesh, worldWidth, worldHeight };\n}\n","import type { EdgeIndex } from \"@scenography/engine\";\nimport type { Measure } from \"@scenography/scene\";\n\nimport type { Artwork } from \"./data\";\n\nconst SURFACE_GAP = 1;\nconst DEFAULT_DEPTH = 2;\n\nconst clamp01 = (t: number): number => Math.min(Math.max(t, 0), 1);\n\nconst parseMeasure = (m: Measure): { value: number; percent: boolean } =>\n typeof m === \"number\" ? { value: m, percent: false } : { value: parseFloat(m), percent: true };\n\n/** Where an artwork sits: world position `[x, y, z]` and Y-axis rotation. */\nexport interface Placement {\n position: [number, number, number];\n yaw: number;\n}\n\n/**\n * Resolve an artwork's placement from its location, the resolved wall edges and\n * the default eye height. Pure geometry — no three.js. Returns `null` for a\n * wall piece whose edge is not (yet) in the index, so the caller can skip it.\n */\nexport function placeArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Placement | null {\n const loc = artwork.location;\n if (loc.type === \"floating\") {\n return { position: [loc.point[0], loc.point[1], loc.point[2]], yaw: loc.yaw ?? 0 };\n }\n\n const edge = edges.get(loc.edge);\n if (!edge) return null;\n\n const [ax, az] = edge.a;\n const [bx, bz] = edge.b;\n const dx = bx - ax;\n const dz = bz - az;\n const length = Math.hypot(dx, dz) || 1;\n const ux = dx / length;\n const uz = dz / length;\n\n // Position along the edge.\n const pos = parseMeasure(loc.position);\n const t = clamp01(pos.percent ? pos.value / 100 : pos.value / length);\n const px = ax + dx * t;\n const pz = az + dz * t;\n\n // Vertical placement: eye height by default, or an explicit absolute/percent.\n let py = eyeHeight;\n if (loc.height !== undefined) {\n const h = parseMeasure(loc.height);\n py = h.percent ? (h.value / 100) * edge.height : h.value;\n }\n\n // Outward normal: right-hand side of the edge by default. Offset by the box\n // half-depth so its back rests against the wall surface.\n const sign = loc.side === \"left\" ? -1 : 1;\n const nx = uz * sign;\n const nz = -ux * sign;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const offset = edge.thickness / 2 + SURFACE_GAP + depth / 2;\n\n return {\n position: [px + nx * offset, py, pz + nz * offset],\n yaw: Math.atan2(nx, nz),\n };\n}\n","import {\n BoxGeometry,\n Group,\n Mesh,\n MeshStandardMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n TextureLoader,\n type Object3D,\n} from \"three\";\nimport { applyTexture, trackAsset, type EdgeIndex } from \"@scenography/engine\";\n\nimport type { Artwork, Frame } from \"./data\";\nimport { buildLabel, LABEL_GAP } from \"./buildLabel\";\nimport { placeArtwork } from \"./placement\";\n\nconst loader = new TextureLoader();\n\nconst DEFAULT_DEPTH = 2;\nconst DEFAULT_FRAME_WIDTH = 5;\nconst DEFAULT_FRAME_DEPTH = 3;\nconst DEFAULT_FRAME_COLOR = \"#2e2622\";\nconst DEFAULT_MAT_WIDTH = 6;\nconst DEFAULT_MAT_COLOR = \"#f4f0e8\";\n\n// Tiny z-offsets stacked on the box front to avoid z-fighting.\nconst MAT_GAP = 0.2;\nconst IMAGE_GAP = 0.4;\n\nconst frameMaterial = (frame: Frame): { material: MeshStandardMaterial; needsUv1: boolean } => {\n const material = new MeshStandardMaterial({\n color: frame.color ?? DEFAULT_FRAME_COLOR,\n roughness: 0.7,\n });\n const needsUv1 = frame.texture ? applyTexture(material, frame.texture) : false;\n return { material, needsUv1 };\n};\n\n/** Four flat bars forming a border around the front face, standing proud of it. */\nfunction buildFrameBars(frame: Frame, width: number, height: number, front: number): Group {\n const fw = frame.width ?? DEFAULT_FRAME_WIDTH;\n const fd = frame.depth ?? DEFAULT_FRAME_DEPTH;\n const { material, needsUv1 } = frameMaterial(frame);\n const group = new Group();\n const z = front + fd / 2;\n\n // Horizontals span the full outer width (covering the corners); verticals fill\n // the gap between them (butt joints).\n const bars: [number, number, number, number][] = [\n [width + 2 * fw, fw, 0, height / 2 + fw / 2], // top\n [width + 2 * fw, fw, 0, -(height / 2 + fw / 2)], // bottom\n [fw, height, -(width / 2 + fw / 2), 0], // left\n [fw, height, width / 2 + fw / 2, 0], // right\n ];\n for (const [bw, bh, x, y] of bars) {\n const geometry = new BoxGeometry(bw, bh, fd);\n if (needsUv1) {\n const uv = geometry.getAttribute(\"uv\");\n if (uv) geometry.setAttribute(\"uv1\", uv);\n }\n const bar = new Mesh(geometry, material);\n bar.position.set(x, y, z);\n bar.castShadow = true;\n bar.receiveShadow = true;\n group.add(bar);\n }\n return group;\n}\n\n/** Load an ornate frame from an external glTF/GLB, placed at the front face. */\nfunction loadFrameModel(group: Group, src: string, front: number): void {\n void import(\"three/examples/jsm/loaders/GLTFLoader.js\").then(({ GLTFLoader }) => {\n new GLTFLoader().load(src, (gltf) => {\n gltf.scene.position.z = front;\n gltf.scene.traverse((child) => {\n child.castShadow = true;\n child.receiveShadow = true;\n });\n group.add(gltf.scene);\n });\n });\n}\n\n/**\n * Build the artwork piece: a box (the image on the front, white sides and back),\n * an optional passepartout mat, and an optional frame (generated bars or an\n * external model). Shared by every location type.\n */\nfunction buildPiece(artwork: Artwork): Group {\n const [width, height] = artwork.size;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const front = depth / 2;\n const group = new Group();\n\n const body = new Mesh(\n new BoxGeometry(width, height, depth),\n new MeshStandardMaterial({ color: \"#ffffff\", roughness: 0.9 }),\n );\n body.castShadow = true;\n body.receiveShadow = true;\n group.add(body);\n\n // Passepartout only applies when there's a frame.\n const frame = artwork.frame;\n const mat = frame?.passepartout;\n let imageWidth = width;\n let imageHeight = height;\n if (mat) {\n const mw = mat.width ?? DEFAULT_MAT_WIDTH;\n const matMesh = new Mesh(\n new PlaneGeometry(width, height),\n new MeshStandardMaterial({ color: mat.color ?? DEFAULT_MAT_COLOR, roughness: 0.95 }),\n );\n matMesh.position.z = front + MAT_GAP;\n matMesh.receiveShadow = true;\n group.add(matMesh);\n imageWidth = Math.max(width - 2 * mw, 1);\n imageHeight = Math.max(height - 2 * mw, 1);\n }\n\n const texture = loader.load(artwork.src, () => trackAsset(artwork.src));\n texture.colorSpace = SRGBColorSpace;\n const image = new Mesh(\n new PlaneGeometry(imageWidth, imageHeight),\n new MeshStandardMaterial({ map: texture, roughness: 0.9 }),\n );\n image.position.z = front + IMAGE_GAP;\n image.receiveShadow = true;\n image.name = \"artwork-image\";\n group.add(image);\n\n if (frame) {\n if (frame.model) loadFrameModel(group, frame.model, front);\n else group.add(buildFrameBars(frame, width, height, front));\n }\n\n return group;\n}\n\n/**\n * Build the three.js object for an `Artwork`, placing it according to its\n * `location`. Wall-hung pieces are positioned along a resolved edge (the box's\n * back resting on the wall); floating pieces use explicit coordinates. Returns\n * `null` if a wall location references an edge that no longer exists.\n */\nexport function buildArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Object3D | null {\n const group = buildPiece(artwork);\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const [pieceWidth, pieceHeight] = artwork.size;\n const front = depth / 2;\n\n // Physical wall label (cartela) next to the piece, when declared. Placed near\n // the wall surface (the box's back), not at the protruding front face. When a\n // label exists it's the click target; otherwise the piece itself is (set below).\n const label = artwork.label ? buildLabel(artwork) : null;\n if (label) {\n const z = -depth / 2 + 0.5;\n const side = artwork.label?.side ?? \"below\";\n // Clear the frame so the cartela isn't tucked under the (proud) frame bars.\n const frameExtent = artwork.frame ? (artwork.frame.width ?? DEFAULT_FRAME_WIDTH) : 0;\n const [ox, oy] = artwork.label?.offset ?? [0, 0];\n if (side === \"below\") {\n const y = -(pieceHeight / 2 + frameExtent + LABEL_GAP + label.worldHeight / 2);\n label.object.position.set(ox, y + oy, z);\n } else {\n const sign = side === \"left\" ? -1 : 1;\n const x = sign * (pieceWidth / 2 + frameExtent + LABEL_GAP + label.worldWidth / 2);\n label.object.position.set(x + ox, oy, z);\n }\n // The label mesh is the pick target; mark only it (not the group) so the\n // piece stays non-interactive when a label is present.\n label.object.userData.pickTarget = artwork.id;\n label.object.userData.pickInfo = { width: label.worldWidth, height: label.worldHeight, frontOffset: 0.2 };\n group.add(label.object);\n } else {\n // No label: the piece is the selector. Mark the group so any of its meshes\n // resolve to this id, and outline the front image face on hover.\n group.userData.pickTarget = artwork.id;\n group.userData.pickInfo = { width: pieceWidth, height: pieceHeight, frontOffset: front + 0.6 };\n }\n\n const placement = placeArtwork(artwork, edges, eyeHeight);\n if (!placement) return null;\n group.position.set(...placement.position);\n group.rotation.y = placement.yaw;\n\n group.name = `artwork:${artwork.id}`;\n group.userData.scenographyId = artwork.id;\n // Real-world face size, consumed by the camera framing to fit the piece.\n group.userData.size = artwork.size;\n return group;\n}\n","import {\n SRGBColorSpace,\n TextureLoader,\n Vector3,\n type Mesh,\n type MeshStandardMaterial,\n type Object3D,\n type PlaneGeometry,\n type Texture,\n} from \"three\";\nimport {\n EDGES,\n EYE_HEIGHT,\n type EdgeIndex,\n type EnginePlugin,\n} from \"@scenography/engine\";\n\nimport { buildArtwork } from \"./buildArtwork\";\nimport { artworkData, type Artwork } from \"./data\";\n\nconst EMPTY_EDGES: EdgeIndex = new Map();\nconst DEFAULT_EYE_HEIGHT = 150;\nconst DEFAULT_DETAIL_DISTANCE = 450;\n/** Full-resolution textures kept on the GPU at once. */\nconst KEEP = 3;\n/** How often the camera distance is checked, in ms. */\nconst TICK = 400;\n\ntype Entry = {\n id: string;\n object: Object3D;\n material: MeshStandardMaterial;\n preview: Texture;\n nativeSrc: string;\n native?: Texture;\n};\n\n/**\n * The artwork plugin: materializes each `Artwork` as a framed box (with an\n * optional physical label), placed on a wall edge or floating. Consumes the\n * `edges` capability (from the wall plugin) to resolve wall placements and\n * `eyeHeight` for the default hang height, rebuilding when either changes.\n *\n * When an artwork carries `nativeSrc`, its `src` is treated as a preview: the\n * full texture loads as the visitor comes within `detailDistance`, keeping at\n * most `KEEP` of them on the GPU.\n */\nexport const artwork = (): EnginePlugin<Artwork> => {\n const entries = new Map<string, Entry>();\n const loaded: string[] = [];\n const loader = new TextureLoader();\n const scratch = new Vector3();\n\n const drop = (id: string): void => {\n const entry = entries.get(id);\n if (!entry?.native) return;\n entry.material.map = entry.preview;\n entry.material.needsUpdate = true;\n entry.native.dispose();\n entry.native = undefined;\n const index = loaded.indexOf(id);\n if (index !== -1) loaded.splice(index, 1);\n };\n\n const touch = (id: string): void => {\n const index = loaded.indexOf(id);\n if (index !== -1) loaded.splice(index, 1);\n loaded.push(id);\n while (loaded.length > KEEP) {\n const evicted = loaded[0];\n if (evicted === undefined) break;\n drop(evicted);\n }\n };\n\n const load = (entry: Entry): void => {\n if (entry.native) return;\n const texture = loader.load(entry.nativeSrc, () => {\n // A load that finishes after an eviction must not come back.\n if (entries.get(entry.id)?.native !== texture) return;\n entry.material.map = texture;\n entry.material.needsUpdate = true;\n });\n texture.colorSpace = SRGBColorSpace;\n entry.native = texture;\n touch(entry.id);\n };\n\n return {\n data: artworkData,\n dependsOn: [EDGES, EYE_HEIGHT],\n build: (definition, ctx) => {\n const edges = ctx.capabilities.get<EdgeIndex>(EDGES) ?? EMPTY_EDGES;\n const eyeHeight = ctx.capabilities.get<number>(EYE_HEIGHT) ?? DEFAULT_EYE_HEIGHT;\n const object = buildArtwork(definition, edges, eyeHeight);\n if (!object) return {};\n if (!definition.nativeSrc) return { object };\n\n const image = object.getObjectByName(\"artwork-image\") as\n | Mesh<PlaneGeometry, MeshStandardMaterial>\n | undefined;\n const preview = image?.material?.map;\n if (!image || !preview) return { object };\n\n const entry: Entry = {\n id: definition.id,\n object,\n material: image.material,\n preview,\n nativeSrc: definition.nativeSrc,\n };\n entries.set(entry.id, entry);\n const distance = definition.detailDistance ?? DEFAULT_DETAIL_DISTANCE;\n let elapsed = 0;\n\n return {\n object,\n update: (deltaMs) => {\n elapsed += deltaMs;\n if (elapsed < TICK) return;\n elapsed = 0;\n object.getWorldPosition(scratch);\n const dx = ctx.camera.position.x - scratch.x;\n const dz = ctx.camera.position.z - scratch.z;\n if (dx * dx + dz * dz > distance * distance) return;\n if (entry.native) touch(entry.id);\n else load(entry);\n },\n dispose: () => {\n entries.delete(entry.id);\n const index = loaded.indexOf(entry.id);\n if (index !== -1) loaded.splice(index, 1);\n entry.native?.dispose();\n entry.native = undefined;\n },\n };\n },\n };\n};\n"],"mappings":"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"}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-
|
|
1
|
+
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-Dl6G3iTG.cjs";
|
|
2
2
|
import { EdgeIndex, EnginePlugin } from "@scenography/engine";
|
|
3
3
|
import { Object3D } from "three";
|
|
4
4
|
|
|
@@ -8,6 +8,10 @@ import { Object3D } from "three";
|
|
|
8
8
|
* optional physical label), placed on a wall edge or floating. Consumes the
|
|
9
9
|
* `edges` capability (from the wall plugin) to resolve wall placements and
|
|
10
10
|
* `eyeHeight` for the default hang height, rebuilding when either changes.
|
|
11
|
+
*
|
|
12
|
+
* When an artwork carries `nativeSrc`, its `src` is treated as a preview: the
|
|
13
|
+
* full texture loads as the visitor comes within `detailDistance`, keeping at
|
|
14
|
+
* most `KEEP` of them on the GPU.
|
|
11
15
|
*/
|
|
12
16
|
declare const artwork: () => EnginePlugin<Artwork>;
|
|
13
17
|
//#endregion
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-
|
|
2
|
-
import { EdgeIndex, EnginePlugin } from "@scenography/engine";
|
|
1
|
+
import { a as ArtworkField, c as Availability, d as Passepartout, i as Artwork, l as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, o as ArtworkLabel, s as ArtworkLocation, t as artworkData, u as Frame } from "./index-Dl6G3iTG.mjs";
|
|
3
2
|
import { Object3D } from "three";
|
|
3
|
+
import { EdgeIndex, EnginePlugin } from "@scenography/engine";
|
|
4
4
|
|
|
5
5
|
//#region src/plugin.d.ts
|
|
6
6
|
/**
|
|
@@ -8,6 +8,10 @@ import { Object3D } from "three";
|
|
|
8
8
|
* optional physical label), placed on a wall edge or floating. Consumes the
|
|
9
9
|
* `edges` capability (from the wall plugin) to resolve wall placements and
|
|
10
10
|
* `eyeHeight` for the default hang height, rebuilding when either changes.
|
|
11
|
+
*
|
|
12
|
+
* When an artwork carries `nativeSrc`, its `src` is treated as a preview: the
|
|
13
|
+
* full texture loads as the visitor comes within `detailDistance`, keeping at
|
|
14
|
+
* most `KEEP` of them on the GPU.
|
|
11
15
|
*/
|
|
12
16
|
declare const artwork: () => EnginePlugin<Artwork>;
|
|
13
17
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { i as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, t as artworkData } from "./data-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { i as DEFAULT_ARTWORK_FIELDS, n as validateArtwork, t as artworkData } from "./data-D1hNczp3.mjs";
|
|
2
|
+
import { BoxGeometry, CanvasTexture, Group, LinearFilter, Mesh, MeshBasicMaterial, MeshStandardMaterial, PlaneGeometry, SRGBColorSpace, TextureLoader, Vector3 } from "three";
|
|
3
|
+
import { EDGES, EYE_HEIGHT, applyTexture, trackAsset } from "@scenography/engine";
|
|
4
4
|
//#region src/buildLabel.ts
|
|
5
5
|
const MM_PER_PT = .3528;
|
|
6
6
|
const UNITS_PER_MM = .1;
|
|
@@ -268,7 +268,7 @@ function buildPiece(artwork) {
|
|
|
268
268
|
imageWidth = Math.max(width - 2 * mw, 1);
|
|
269
269
|
imageHeight = Math.max(height - 2 * mw, 1);
|
|
270
270
|
}
|
|
271
|
-
const texture = loader.load(artwork.src);
|
|
271
|
+
const texture = loader.load(artwork.src, () => trackAsset(artwork.src));
|
|
272
272
|
texture.colorSpace = SRGBColorSpace;
|
|
273
273
|
const image = new Mesh(new PlaneGeometry(imageWidth, imageHeight), new MeshStandardMaterial({
|
|
274
274
|
map: texture,
|
|
@@ -276,6 +276,7 @@ function buildPiece(artwork) {
|
|
|
276
276
|
}));
|
|
277
277
|
image.position.z = front + IMAGE_GAP;
|
|
278
278
|
image.receiveShadow = true;
|
|
279
|
+
image.name = "artwork-image";
|
|
279
280
|
group.add(image);
|
|
280
281
|
if (frame) if (frame.model) loadFrameModel(group, frame.model, front);
|
|
281
282
|
else group.add(buildFrameBars(frame, width, height, front));
|
|
@@ -333,20 +334,101 @@ function buildArtwork(artwork, edges, eyeHeight) {
|
|
|
333
334
|
//#region src/plugin.ts
|
|
334
335
|
const EMPTY_EDGES = /* @__PURE__ */ new Map();
|
|
335
336
|
const DEFAULT_EYE_HEIGHT = 150;
|
|
337
|
+
const DEFAULT_DETAIL_DISTANCE = 450;
|
|
338
|
+
/** Full-resolution textures kept on the GPU at once. */
|
|
339
|
+
const KEEP = 3;
|
|
340
|
+
/** How often the camera distance is checked, in ms. */
|
|
341
|
+
const TICK = 400;
|
|
336
342
|
/**
|
|
337
343
|
* The artwork plugin: materializes each `Artwork` as a framed box (with an
|
|
338
344
|
* optional physical label), placed on a wall edge or floating. Consumes the
|
|
339
345
|
* `edges` capability (from the wall plugin) to resolve wall placements and
|
|
340
346
|
* `eyeHeight` for the default hang height, rebuilding when either changes.
|
|
347
|
+
*
|
|
348
|
+
* When an artwork carries `nativeSrc`, its `src` is treated as a preview: the
|
|
349
|
+
* full texture loads as the visitor comes within `detailDistance`, keeping at
|
|
350
|
+
* most `KEEP` of them on the GPU.
|
|
341
351
|
*/
|
|
342
|
-
const artwork = () =>
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
352
|
+
const artwork = () => {
|
|
353
|
+
const entries = /* @__PURE__ */ new Map();
|
|
354
|
+
const loaded = [];
|
|
355
|
+
const loader = new TextureLoader();
|
|
356
|
+
const scratch = new Vector3();
|
|
357
|
+
const drop = (id) => {
|
|
358
|
+
const entry = entries.get(id);
|
|
359
|
+
if (!entry?.native) return;
|
|
360
|
+
entry.material.map = entry.preview;
|
|
361
|
+
entry.material.needsUpdate = true;
|
|
362
|
+
entry.native.dispose();
|
|
363
|
+
entry.native = void 0;
|
|
364
|
+
const index = loaded.indexOf(id);
|
|
365
|
+
if (index !== -1) loaded.splice(index, 1);
|
|
366
|
+
};
|
|
367
|
+
const touch = (id) => {
|
|
368
|
+
const index = loaded.indexOf(id);
|
|
369
|
+
if (index !== -1) loaded.splice(index, 1);
|
|
370
|
+
loaded.push(id);
|
|
371
|
+
while (loaded.length > KEEP) {
|
|
372
|
+
const evicted = loaded[0];
|
|
373
|
+
if (evicted === void 0) break;
|
|
374
|
+
drop(evicted);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const load = (entry) => {
|
|
378
|
+
if (entry.native) return;
|
|
379
|
+
const texture = loader.load(entry.nativeSrc, () => {
|
|
380
|
+
if (entries.get(entry.id)?.native !== texture) return;
|
|
381
|
+
entry.material.map = texture;
|
|
382
|
+
entry.material.needsUpdate = true;
|
|
383
|
+
});
|
|
384
|
+
texture.colorSpace = SRGBColorSpace;
|
|
385
|
+
entry.native = texture;
|
|
386
|
+
touch(entry.id);
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
data: artworkData,
|
|
390
|
+
dependsOn: [EDGES, EYE_HEIGHT],
|
|
391
|
+
build: (definition, ctx) => {
|
|
392
|
+
const object = buildArtwork(definition, ctx.capabilities.get(EDGES) ?? EMPTY_EDGES, ctx.capabilities.get(EYE_HEIGHT) ?? DEFAULT_EYE_HEIGHT);
|
|
393
|
+
if (!object) return {};
|
|
394
|
+
if (!definition.nativeSrc) return { object };
|
|
395
|
+
const image = object.getObjectByName("artwork-image");
|
|
396
|
+
const preview = image?.material?.map;
|
|
397
|
+
if (!image || !preview) return { object };
|
|
398
|
+
const entry = {
|
|
399
|
+
id: definition.id,
|
|
400
|
+
object,
|
|
401
|
+
material: image.material,
|
|
402
|
+
preview,
|
|
403
|
+
nativeSrc: definition.nativeSrc
|
|
404
|
+
};
|
|
405
|
+
entries.set(entry.id, entry);
|
|
406
|
+
const distance = definition.detailDistance ?? DEFAULT_DETAIL_DISTANCE;
|
|
407
|
+
let elapsed = 0;
|
|
408
|
+
return {
|
|
409
|
+
object,
|
|
410
|
+
update: (deltaMs) => {
|
|
411
|
+
elapsed += deltaMs;
|
|
412
|
+
if (elapsed < TICK) return;
|
|
413
|
+
elapsed = 0;
|
|
414
|
+
object.getWorldPosition(scratch);
|
|
415
|
+
const dx = ctx.camera.position.x - scratch.x;
|
|
416
|
+
const dz = ctx.camera.position.z - scratch.z;
|
|
417
|
+
if (dx * dx + dz * dz > distance * distance) return;
|
|
418
|
+
if (entry.native) touch(entry.id);
|
|
419
|
+
else load(entry);
|
|
420
|
+
},
|
|
421
|
+
dispose: () => {
|
|
422
|
+
entries.delete(entry.id);
|
|
423
|
+
const index = loaded.indexOf(entry.id);
|
|
424
|
+
if (index !== -1) loaded.splice(index, 1);
|
|
425
|
+
entry.native?.dispose();
|
|
426
|
+
entry.native = void 0;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
};
|
|
350
432
|
//#endregion
|
|
351
433
|
export { DEFAULT_ARTWORK_FIELDS, artwork, artworkData, buildArtwork, validateArtwork };
|
|
352
434
|
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["DEFAULT_DEPTH"],"sources":["../src/buildLabel.ts","../src/placement.ts","../src/buildArtwork.ts","../src/plugin.ts"],"sourcesContent":["import {\n CanvasTexture,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n type Object3D,\n} from \"three\";\nimport { DEFAULT_ARTWORK_FIELDS, type Artwork, type ArtworkField } from \"./data\";\n\n// World units are centimeters (100 units = 1 m), so the label is sized to real\n// physical dimensions. Text is rasterized to a canvas (zero assets shipped); the\n// canvas resolution (px per world unit) only affects crispness, not size.\nconst MM_PER_PT = 0.3528;\nconst UNITS_PER_MM = 0.1; // 1 unit = 1 cm = 10 mm\nconst PX_PER_UNIT = 48; // canvas resolution\n\nconst ptToUnits = (pt: number): number => pt * MM_PER_PT * UNITS_PER_MM;\nconst ptToPx = (pt: number): number => ptToUnits(pt) * PX_PER_UNIT;\n\nconst TITLE_PT = 24;\nconst BODY_PT = 18;\nconst DOT_DIAMETER_UNITS = 0.5; // 5 mm\nconst PAD_PX = 4 * UNITS_PER_MM * PX_PER_UNIT; // ~4 mm padding\nconst LINE_GAP_PX = 2 * UNITS_PER_MM * PX_PER_UNIT; // ~2 mm between lines\n\n/** Gap between the piece and its label, in world units (cm). */\nexport const LABEL_GAP = 3;\n\nconst AVAILABILITY_COLOR: Record<NonNullable<Artwork[\"availability\"]>, string> = {\n \"for-sale\": \"#22c55e\",\n sold: \"#ef4444\",\n \"not-for-sale\": \"#ef4444\",\n};\n\nconst has = (fields: readonly ArtworkField[], field: ArtworkField): boolean =>\n fields.includes(field);\nconst joinParts = (parts: (string | undefined)[], sep: string): string =>\n parts.filter((p): p is string => Boolean(p)).join(sep);\n\n/** A built physical label plus its real-world size (for placement/picking). */\nexport interface BuiltLabel {\n object: Object3D;\n worldWidth: number;\n worldHeight: number;\n}\n\n/**\n * Build a physical wall label (cartela) for an artwork, rendered to a canvas\n * texture at real physical size: a bold 24pt title, 18pt body, and a 5 mm\n * availability dot in the top-right corner. Field visibility follows\n * `artwork.label.fields` (default `DEFAULT_ARTWORK_FIELDS`); render order is fixed\n * regardless of array order. Returns `null` when nothing is visible.\n */\nexport function buildLabel(artwork: Artwork): BuiltLabel | null {\n const fields = artwork.label?.fields ?? DEFAULT_ARTWORK_FIELDS;\n\n const lines: { text: string; px: number; bold: boolean }[] = [];\n if (has(fields, \"title\") && artwork.title)\n lines.push({ text: artwork.title, px: ptToPx(TITLE_PT), bold: true });\n const authorYear = joinParts(\n [\n has(fields, \"author\") ? artwork.author : undefined,\n has(fields, \"year\") ? artwork.year?.toString() : undefined,\n ],\n \", \",\n );\n if (authorYear) lines.push({ text: authorYear, px: ptToPx(BODY_PT), bold: false });\n const techDims = joinParts(\n [\n has(fields, \"technique\") ? artwork.technique : undefined,\n has(fields, \"dimensions\") ? artwork.dimensions : undefined,\n ],\n \", \",\n );\n if (techDims) lines.push({ text: techDims, px: ptToPx(BODY_PT), bold: false });\n\n const showDot = has(fields, \"availability\") && artwork.availability !== undefined;\n if (lines.length === 0 && !showDot) return null;\n\n const canvas = document.createElement(\"canvas\");\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return null;\n\n const font = (line: { px: number; bold: boolean }): string =>\n `${line.bold ? \"700\" : \"400\"} ${line.px}px system-ui, sans-serif`;\n\n // Width from the widest line (plus room for the dot on the first line), height\n // from the stacked lines — so the card is exactly as big as its content.\n const dotPx = DOT_DIAMETER_UNITS * PX_PER_UNIT;\n let maxTextWidth = 0;\n for (const line of lines) {\n ctx.font = font(line);\n maxTextWidth = Math.max(maxTextWidth, ctx.measureText(line.text).width);\n }\n const dotReserve = showDot ? dotPx + LINE_GAP_PX : 0;\n canvas.width = Math.ceil(maxTextWidth + dotReserve + 2 * PAD_PX);\n const contentHeight = lines.reduce((sum, l) => sum + l.px + LINE_GAP_PX, 0);\n canvas.height = Math.ceil(Math.max(contentHeight, dotPx) + 2 * PAD_PX);\n\n ctx.fillStyle = \"#f7f5f0\";\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n ctx.textBaseline = \"top\";\n ctx.fillStyle = \"#1c1917\";\n let y = PAD_PX;\n for (const line of lines) {\n ctx.font = font(line);\n ctx.fillText(line.text, PAD_PX, y);\n y += line.px + LINE_GAP_PX;\n }\n\n if (showDot) {\n ctx.beginPath();\n ctx.arc(canvas.width - PAD_PX - dotPx / 2, PAD_PX + dotPx / 2, dotPx / 2, 0, Math.PI * 2);\n ctx.fillStyle = AVAILABILITY_COLOR[artwork.availability as NonNullable<Artwork[\"availability\"]>];\n ctx.fill();\n }\n\n const texture = new CanvasTexture(canvas);\n texture.colorSpace = SRGBColorSpace;\n texture.minFilter = LinearFilter;\n texture.magFilter = LinearFilter;\n\n // Real physical size, optionally scaled up for legibility (the canvas\n // resolution is unchanged, so the text just renders larger on the plane).\n const scale = artwork.label?.scale ?? 1;\n const worldWidth = (canvas.width / PX_PER_UNIT) * scale;\n const worldHeight = (canvas.height / PX_PER_UNIT) * scale;\n const mesh = new Mesh(\n new PlaneGeometry(worldWidth, worldHeight),\n new MeshBasicMaterial({ map: texture, transparent: true }),\n );\n mesh.name = `label:${artwork.id}`;\n return { object: mesh, worldWidth, worldHeight };\n}\n","import type { EdgeIndex } from \"@scenography/engine\";\nimport type { Measure } from \"@scenography/scene\";\n\nimport type { Artwork } from \"./data\";\n\nconst SURFACE_GAP = 1;\nconst DEFAULT_DEPTH = 2;\n\nconst clamp01 = (t: number): number => Math.min(Math.max(t, 0), 1);\n\nconst parseMeasure = (m: Measure): { value: number; percent: boolean } =>\n typeof m === \"number\" ? { value: m, percent: false } : { value: parseFloat(m), percent: true };\n\n/** Where an artwork sits: world position `[x, y, z]` and Y-axis rotation. */\nexport interface Placement {\n position: [number, number, number];\n yaw: number;\n}\n\n/**\n * Resolve an artwork's placement from its location, the resolved wall edges and\n * the default eye height. Pure geometry — no three.js. Returns `null` for a\n * wall piece whose edge is not (yet) in the index, so the caller can skip it.\n */\nexport function placeArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Placement | null {\n const loc = artwork.location;\n if (loc.type === \"floating\") {\n return { position: [loc.point[0], loc.point[1], loc.point[2]], yaw: loc.yaw ?? 0 };\n }\n\n const edge = edges.get(loc.edge);\n if (!edge) return null;\n\n const [ax, az] = edge.a;\n const [bx, bz] = edge.b;\n const dx = bx - ax;\n const dz = bz - az;\n const length = Math.hypot(dx, dz) || 1;\n const ux = dx / length;\n const uz = dz / length;\n\n // Position along the edge.\n const pos = parseMeasure(loc.position);\n const t = clamp01(pos.percent ? pos.value / 100 : pos.value / length);\n const px = ax + dx * t;\n const pz = az + dz * t;\n\n // Vertical placement: eye height by default, or an explicit absolute/percent.\n let py = eyeHeight;\n if (loc.height !== undefined) {\n const h = parseMeasure(loc.height);\n py = h.percent ? (h.value / 100) * edge.height : h.value;\n }\n\n // Outward normal: right-hand side of the edge by default. Offset by the box\n // half-depth so its back rests against the wall surface.\n const sign = loc.side === \"left\" ? -1 : 1;\n const nx = uz * sign;\n const nz = -ux * sign;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const offset = edge.thickness / 2 + SURFACE_GAP + depth / 2;\n\n return {\n position: [px + nx * offset, py, pz + nz * offset],\n yaw: Math.atan2(nx, nz),\n };\n}\n","import {\n BoxGeometry,\n Group,\n Mesh,\n MeshStandardMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n TextureLoader,\n type Object3D,\n} from \"three\";\nimport { applyTexture, type EdgeIndex } from \"@scenography/engine\";\n\nimport type { Artwork, Frame } from \"./data\";\nimport { buildLabel, LABEL_GAP } from \"./buildLabel\";\nimport { placeArtwork } from \"./placement\";\n\nconst loader = new TextureLoader();\n\nconst DEFAULT_DEPTH = 2;\nconst DEFAULT_FRAME_WIDTH = 5;\nconst DEFAULT_FRAME_DEPTH = 3;\nconst DEFAULT_FRAME_COLOR = \"#2e2622\";\nconst DEFAULT_MAT_WIDTH = 6;\nconst DEFAULT_MAT_COLOR = \"#f4f0e8\";\n\n// Tiny z-offsets stacked on the box front to avoid z-fighting.\nconst MAT_GAP = 0.2;\nconst IMAGE_GAP = 0.4;\n\nconst frameMaterial = (frame: Frame): { material: MeshStandardMaterial; needsUv1: boolean } => {\n const material = new MeshStandardMaterial({\n color: frame.color ?? DEFAULT_FRAME_COLOR,\n roughness: 0.7,\n });\n const needsUv1 = frame.texture ? applyTexture(material, frame.texture) : false;\n return { material, needsUv1 };\n};\n\n/** Four flat bars forming a border around the front face, standing proud of it. */\nfunction buildFrameBars(frame: Frame, width: number, height: number, front: number): Group {\n const fw = frame.width ?? DEFAULT_FRAME_WIDTH;\n const fd = frame.depth ?? DEFAULT_FRAME_DEPTH;\n const { material, needsUv1 } = frameMaterial(frame);\n const group = new Group();\n const z = front + fd / 2;\n\n // Horizontals span the full outer width (covering the corners); verticals fill\n // the gap between them (butt joints).\n const bars: [number, number, number, number][] = [\n [width + 2 * fw, fw, 0, height / 2 + fw / 2], // top\n [width + 2 * fw, fw, 0, -(height / 2 + fw / 2)], // bottom\n [fw, height, -(width / 2 + fw / 2), 0], // left\n [fw, height, width / 2 + fw / 2, 0], // right\n ];\n for (const [bw, bh, x, y] of bars) {\n const geometry = new BoxGeometry(bw, bh, fd);\n if (needsUv1) {\n const uv = geometry.getAttribute(\"uv\");\n if (uv) geometry.setAttribute(\"uv1\", uv);\n }\n const bar = new Mesh(geometry, material);\n bar.position.set(x, y, z);\n bar.castShadow = true;\n bar.receiveShadow = true;\n group.add(bar);\n }\n return group;\n}\n\n/** Load an ornate frame from an external glTF/GLB, placed at the front face. */\nfunction loadFrameModel(group: Group, src: string, front: number): void {\n void import(\"three/examples/jsm/loaders/GLTFLoader.js\").then(({ GLTFLoader }) => {\n new GLTFLoader().load(src, (gltf) => {\n gltf.scene.position.z = front;\n gltf.scene.traverse((child) => {\n child.castShadow = true;\n child.receiveShadow = true;\n });\n group.add(gltf.scene);\n });\n });\n}\n\n/**\n * Build the artwork piece: a box (the image on the front, white sides and back),\n * an optional passepartout mat, and an optional frame (generated bars or an\n * external model). Shared by every location type.\n */\nfunction buildPiece(artwork: Artwork): Group {\n const [width, height] = artwork.size;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const front = depth / 2;\n const group = new Group();\n\n const body = new Mesh(\n new BoxGeometry(width, height, depth),\n new MeshStandardMaterial({ color: \"#ffffff\", roughness: 0.9 }),\n );\n body.castShadow = true;\n body.receiveShadow = true;\n group.add(body);\n\n // Passepartout only applies when there's a frame.\n const frame = artwork.frame;\n const mat = frame?.passepartout;\n let imageWidth = width;\n let imageHeight = height;\n if (mat) {\n const mw = mat.width ?? DEFAULT_MAT_WIDTH;\n const matMesh = new Mesh(\n new PlaneGeometry(width, height),\n new MeshStandardMaterial({ color: mat.color ?? DEFAULT_MAT_COLOR, roughness: 0.95 }),\n );\n matMesh.position.z = front + MAT_GAP;\n matMesh.receiveShadow = true;\n group.add(matMesh);\n imageWidth = Math.max(width - 2 * mw, 1);\n imageHeight = Math.max(height - 2 * mw, 1);\n }\n\n const texture = loader.load(artwork.src);\n texture.colorSpace = SRGBColorSpace;\n const image = new Mesh(\n new PlaneGeometry(imageWidth, imageHeight),\n new MeshStandardMaterial({ map: texture, roughness: 0.9 }),\n );\n image.position.z = front + IMAGE_GAP;\n image.receiveShadow = true;\n group.add(image);\n\n if (frame) {\n if (frame.model) loadFrameModel(group, frame.model, front);\n else group.add(buildFrameBars(frame, width, height, front));\n }\n\n return group;\n}\n\n/**\n * Build the three.js object for an `Artwork`, placing it according to its\n * `location`. Wall-hung pieces are positioned along a resolved edge (the box's\n * back resting on the wall); floating pieces use explicit coordinates. Returns\n * `null` if a wall location references an edge that no longer exists.\n */\nexport function buildArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Object3D | null {\n const group = buildPiece(artwork);\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const [pieceWidth, pieceHeight] = artwork.size;\n const front = depth / 2;\n\n // Physical wall label (cartela) next to the piece, when declared. Placed near\n // the wall surface (the box's back), not at the protruding front face. When a\n // label exists it's the click target; otherwise the piece itself is (set below).\n const label = artwork.label ? buildLabel(artwork) : null;\n if (label) {\n const z = -depth / 2 + 0.5;\n const side = artwork.label?.side ?? \"below\";\n // Clear the frame so the cartela isn't tucked under the (proud) frame bars.\n const frameExtent = artwork.frame ? (artwork.frame.width ?? DEFAULT_FRAME_WIDTH) : 0;\n const [ox, oy] = artwork.label?.offset ?? [0, 0];\n if (side === \"below\") {\n const y = -(pieceHeight / 2 + frameExtent + LABEL_GAP + label.worldHeight / 2);\n label.object.position.set(ox, y + oy, z);\n } else {\n const sign = side === \"left\" ? -1 : 1;\n const x = sign * (pieceWidth / 2 + frameExtent + LABEL_GAP + label.worldWidth / 2);\n label.object.position.set(x + ox, oy, z);\n }\n // The label mesh is the pick target; mark only it (not the group) so the\n // piece stays non-interactive when a label is present.\n label.object.userData.pickTarget = artwork.id;\n label.object.userData.pickInfo = { width: label.worldWidth, height: label.worldHeight, frontOffset: 0.2 };\n group.add(label.object);\n } else {\n // No label: the piece is the selector. Mark the group so any of its meshes\n // resolve to this id, and outline the front image face on hover.\n group.userData.pickTarget = artwork.id;\n group.userData.pickInfo = { width: pieceWidth, height: pieceHeight, frontOffset: front + 0.6 };\n }\n\n const placement = placeArtwork(artwork, edges, eyeHeight);\n if (!placement) return null;\n group.position.set(...placement.position);\n group.rotation.y = placement.yaw;\n\n group.name = `artwork:${artwork.id}`;\n group.userData.scenographyId = artwork.id;\n // Real-world face size, consumed by the camera framing to fit the piece.\n group.userData.size = artwork.size;\n return group;\n}\n","import { EDGES, EYE_HEIGHT, type EdgeIndex, type EnginePlugin } from \"@scenography/engine\";\n\nimport { buildArtwork } from \"./buildArtwork\";\nimport { artworkData, type Artwork } from \"./data\";\n\nconst EMPTY_EDGES: EdgeIndex = new Map();\nconst DEFAULT_EYE_HEIGHT = 150;\n\n/**\n * The artwork plugin: materializes each `Artwork` as a framed box (with an\n * optional physical label), placed on a wall edge or floating. Consumes the\n * `edges` capability (from the wall plugin) to resolve wall placements and\n * `eyeHeight` for the default hang height, rebuilding when either changes.\n */\nexport const artwork = (): EnginePlugin<Artwork> => ({\n data: artworkData,\n dependsOn: [EDGES, EYE_HEIGHT],\n build: (definition, ctx) => {\n const edges = ctx.capabilities.get<EdgeIndex>(EDGES) ?? EMPTY_EDGES;\n const eyeHeight = ctx.capabilities.get<number>(EYE_HEIGHT) ?? DEFAULT_EYE_HEIGHT;\n const object = buildArtwork(definition, edges, eyeHeight);\n return object ? { object } : {};\n },\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["DEFAULT_DEPTH"],"sources":["../src/buildLabel.ts","../src/placement.ts","../src/buildArtwork.ts","../src/plugin.ts"],"sourcesContent":["import {\n CanvasTexture,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n type Object3D,\n} from \"three\";\nimport { DEFAULT_ARTWORK_FIELDS, type Artwork, type ArtworkField } from \"./data\";\n\n// World units are centimeters (100 units = 1 m), so the label is sized to real\n// physical dimensions. Text is rasterized to a canvas (zero assets shipped); the\n// canvas resolution (px per world unit) only affects crispness, not size.\nconst MM_PER_PT = 0.3528;\nconst UNITS_PER_MM = 0.1; // 1 unit = 1 cm = 10 mm\nconst PX_PER_UNIT = 48; // canvas resolution\n\nconst ptToUnits = (pt: number): number => pt * MM_PER_PT * UNITS_PER_MM;\nconst ptToPx = (pt: number): number => ptToUnits(pt) * PX_PER_UNIT;\n\nconst TITLE_PT = 24;\nconst BODY_PT = 18;\nconst DOT_DIAMETER_UNITS = 0.5; // 5 mm\nconst PAD_PX = 4 * UNITS_PER_MM * PX_PER_UNIT; // ~4 mm padding\nconst LINE_GAP_PX = 2 * UNITS_PER_MM * PX_PER_UNIT; // ~2 mm between lines\n\n/** Gap between the piece and its label, in world units (cm). */\nexport const LABEL_GAP = 3;\n\nconst AVAILABILITY_COLOR: Record<NonNullable<Artwork[\"availability\"]>, string> = {\n \"for-sale\": \"#22c55e\",\n sold: \"#ef4444\",\n \"not-for-sale\": \"#ef4444\",\n};\n\nconst has = (fields: readonly ArtworkField[], field: ArtworkField): boolean =>\n fields.includes(field);\nconst joinParts = (parts: (string | undefined)[], sep: string): string =>\n parts.filter((p): p is string => Boolean(p)).join(sep);\n\n/** A built physical label plus its real-world size (for placement/picking). */\nexport interface BuiltLabel {\n object: Object3D;\n worldWidth: number;\n worldHeight: number;\n}\n\n/**\n * Build a physical wall label (cartela) for an artwork, rendered to a canvas\n * texture at real physical size: a bold 24pt title, 18pt body, and a 5 mm\n * availability dot in the top-right corner. Field visibility follows\n * `artwork.label.fields` (default `DEFAULT_ARTWORK_FIELDS`); render order is fixed\n * regardless of array order. Returns `null` when nothing is visible.\n */\nexport function buildLabel(artwork: Artwork): BuiltLabel | null {\n const fields = artwork.label?.fields ?? DEFAULT_ARTWORK_FIELDS;\n\n const lines: { text: string; px: number; bold: boolean }[] = [];\n if (has(fields, \"title\") && artwork.title)\n lines.push({ text: artwork.title, px: ptToPx(TITLE_PT), bold: true });\n const authorYear = joinParts(\n [\n has(fields, \"author\") ? artwork.author : undefined,\n has(fields, \"year\") ? artwork.year?.toString() : undefined,\n ],\n \", \",\n );\n if (authorYear) lines.push({ text: authorYear, px: ptToPx(BODY_PT), bold: false });\n const techDims = joinParts(\n [\n has(fields, \"technique\") ? artwork.technique : undefined,\n has(fields, \"dimensions\") ? artwork.dimensions : undefined,\n ],\n \", \",\n );\n if (techDims) lines.push({ text: techDims, px: ptToPx(BODY_PT), bold: false });\n\n const showDot = has(fields, \"availability\") && artwork.availability !== undefined;\n if (lines.length === 0 && !showDot) return null;\n\n const canvas = document.createElement(\"canvas\");\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return null;\n\n const font = (line: { px: number; bold: boolean }): string =>\n `${line.bold ? \"700\" : \"400\"} ${line.px}px system-ui, sans-serif`;\n\n // Width from the widest line (plus room for the dot on the first line), height\n // from the stacked lines — so the card is exactly as big as its content.\n const dotPx = DOT_DIAMETER_UNITS * PX_PER_UNIT;\n let maxTextWidth = 0;\n for (const line of lines) {\n ctx.font = font(line);\n maxTextWidth = Math.max(maxTextWidth, ctx.measureText(line.text).width);\n }\n const dotReserve = showDot ? dotPx + LINE_GAP_PX : 0;\n canvas.width = Math.ceil(maxTextWidth + dotReserve + 2 * PAD_PX);\n const contentHeight = lines.reduce((sum, l) => sum + l.px + LINE_GAP_PX, 0);\n canvas.height = Math.ceil(Math.max(contentHeight, dotPx) + 2 * PAD_PX);\n\n ctx.fillStyle = \"#f7f5f0\";\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n ctx.textBaseline = \"top\";\n ctx.fillStyle = \"#1c1917\";\n let y = PAD_PX;\n for (const line of lines) {\n ctx.font = font(line);\n ctx.fillText(line.text, PAD_PX, y);\n y += line.px + LINE_GAP_PX;\n }\n\n if (showDot) {\n ctx.beginPath();\n ctx.arc(canvas.width - PAD_PX - dotPx / 2, PAD_PX + dotPx / 2, dotPx / 2, 0, Math.PI * 2);\n ctx.fillStyle = AVAILABILITY_COLOR[artwork.availability as NonNullable<Artwork[\"availability\"]>];\n ctx.fill();\n }\n\n const texture = new CanvasTexture(canvas);\n texture.colorSpace = SRGBColorSpace;\n texture.minFilter = LinearFilter;\n texture.magFilter = LinearFilter;\n\n // Real physical size, optionally scaled up for legibility (the canvas\n // resolution is unchanged, so the text just renders larger on the plane).\n const scale = artwork.label?.scale ?? 1;\n const worldWidth = (canvas.width / PX_PER_UNIT) * scale;\n const worldHeight = (canvas.height / PX_PER_UNIT) * scale;\n const mesh = new Mesh(\n new PlaneGeometry(worldWidth, worldHeight),\n new MeshBasicMaterial({ map: texture, transparent: true }),\n );\n mesh.name = `label:${artwork.id}`;\n return { object: mesh, worldWidth, worldHeight };\n}\n","import type { EdgeIndex } from \"@scenography/engine\";\nimport type { Measure } from \"@scenography/scene\";\n\nimport type { Artwork } from \"./data\";\n\nconst SURFACE_GAP = 1;\nconst DEFAULT_DEPTH = 2;\n\nconst clamp01 = (t: number): number => Math.min(Math.max(t, 0), 1);\n\nconst parseMeasure = (m: Measure): { value: number; percent: boolean } =>\n typeof m === \"number\" ? { value: m, percent: false } : { value: parseFloat(m), percent: true };\n\n/** Where an artwork sits: world position `[x, y, z]` and Y-axis rotation. */\nexport interface Placement {\n position: [number, number, number];\n yaw: number;\n}\n\n/**\n * Resolve an artwork's placement from its location, the resolved wall edges and\n * the default eye height. Pure geometry — no three.js. Returns `null` for a\n * wall piece whose edge is not (yet) in the index, so the caller can skip it.\n */\nexport function placeArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Placement | null {\n const loc = artwork.location;\n if (loc.type === \"floating\") {\n return { position: [loc.point[0], loc.point[1], loc.point[2]], yaw: loc.yaw ?? 0 };\n }\n\n const edge = edges.get(loc.edge);\n if (!edge) return null;\n\n const [ax, az] = edge.a;\n const [bx, bz] = edge.b;\n const dx = bx - ax;\n const dz = bz - az;\n const length = Math.hypot(dx, dz) || 1;\n const ux = dx / length;\n const uz = dz / length;\n\n // Position along the edge.\n const pos = parseMeasure(loc.position);\n const t = clamp01(pos.percent ? pos.value / 100 : pos.value / length);\n const px = ax + dx * t;\n const pz = az + dz * t;\n\n // Vertical placement: eye height by default, or an explicit absolute/percent.\n let py = eyeHeight;\n if (loc.height !== undefined) {\n const h = parseMeasure(loc.height);\n py = h.percent ? (h.value / 100) * edge.height : h.value;\n }\n\n // Outward normal: right-hand side of the edge by default. Offset by the box\n // half-depth so its back rests against the wall surface.\n const sign = loc.side === \"left\" ? -1 : 1;\n const nx = uz * sign;\n const nz = -ux * sign;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const offset = edge.thickness / 2 + SURFACE_GAP + depth / 2;\n\n return {\n position: [px + nx * offset, py, pz + nz * offset],\n yaw: Math.atan2(nx, nz),\n };\n}\n","import {\n BoxGeometry,\n Group,\n Mesh,\n MeshStandardMaterial,\n PlaneGeometry,\n SRGBColorSpace,\n TextureLoader,\n type Object3D,\n} from \"three\";\nimport { applyTexture, trackAsset, type EdgeIndex } from \"@scenography/engine\";\n\nimport type { Artwork, Frame } from \"./data\";\nimport { buildLabel, LABEL_GAP } from \"./buildLabel\";\nimport { placeArtwork } from \"./placement\";\n\nconst loader = new TextureLoader();\n\nconst DEFAULT_DEPTH = 2;\nconst DEFAULT_FRAME_WIDTH = 5;\nconst DEFAULT_FRAME_DEPTH = 3;\nconst DEFAULT_FRAME_COLOR = \"#2e2622\";\nconst DEFAULT_MAT_WIDTH = 6;\nconst DEFAULT_MAT_COLOR = \"#f4f0e8\";\n\n// Tiny z-offsets stacked on the box front to avoid z-fighting.\nconst MAT_GAP = 0.2;\nconst IMAGE_GAP = 0.4;\n\nconst frameMaterial = (frame: Frame): { material: MeshStandardMaterial; needsUv1: boolean } => {\n const material = new MeshStandardMaterial({\n color: frame.color ?? DEFAULT_FRAME_COLOR,\n roughness: 0.7,\n });\n const needsUv1 = frame.texture ? applyTexture(material, frame.texture) : false;\n return { material, needsUv1 };\n};\n\n/** Four flat bars forming a border around the front face, standing proud of it. */\nfunction buildFrameBars(frame: Frame, width: number, height: number, front: number): Group {\n const fw = frame.width ?? DEFAULT_FRAME_WIDTH;\n const fd = frame.depth ?? DEFAULT_FRAME_DEPTH;\n const { material, needsUv1 } = frameMaterial(frame);\n const group = new Group();\n const z = front + fd / 2;\n\n // Horizontals span the full outer width (covering the corners); verticals fill\n // the gap between them (butt joints).\n const bars: [number, number, number, number][] = [\n [width + 2 * fw, fw, 0, height / 2 + fw / 2], // top\n [width + 2 * fw, fw, 0, -(height / 2 + fw / 2)], // bottom\n [fw, height, -(width / 2 + fw / 2), 0], // left\n [fw, height, width / 2 + fw / 2, 0], // right\n ];\n for (const [bw, bh, x, y] of bars) {\n const geometry = new BoxGeometry(bw, bh, fd);\n if (needsUv1) {\n const uv = geometry.getAttribute(\"uv\");\n if (uv) geometry.setAttribute(\"uv1\", uv);\n }\n const bar = new Mesh(geometry, material);\n bar.position.set(x, y, z);\n bar.castShadow = true;\n bar.receiveShadow = true;\n group.add(bar);\n }\n return group;\n}\n\n/** Load an ornate frame from an external glTF/GLB, placed at the front face. */\nfunction loadFrameModel(group: Group, src: string, front: number): void {\n void import(\"three/examples/jsm/loaders/GLTFLoader.js\").then(({ GLTFLoader }) => {\n new GLTFLoader().load(src, (gltf) => {\n gltf.scene.position.z = front;\n gltf.scene.traverse((child) => {\n child.castShadow = true;\n child.receiveShadow = true;\n });\n group.add(gltf.scene);\n });\n });\n}\n\n/**\n * Build the artwork piece: a box (the image on the front, white sides and back),\n * an optional passepartout mat, and an optional frame (generated bars or an\n * external model). Shared by every location type.\n */\nfunction buildPiece(artwork: Artwork): Group {\n const [width, height] = artwork.size;\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const front = depth / 2;\n const group = new Group();\n\n const body = new Mesh(\n new BoxGeometry(width, height, depth),\n new MeshStandardMaterial({ color: \"#ffffff\", roughness: 0.9 }),\n );\n body.castShadow = true;\n body.receiveShadow = true;\n group.add(body);\n\n // Passepartout only applies when there's a frame.\n const frame = artwork.frame;\n const mat = frame?.passepartout;\n let imageWidth = width;\n let imageHeight = height;\n if (mat) {\n const mw = mat.width ?? DEFAULT_MAT_WIDTH;\n const matMesh = new Mesh(\n new PlaneGeometry(width, height),\n new MeshStandardMaterial({ color: mat.color ?? DEFAULT_MAT_COLOR, roughness: 0.95 }),\n );\n matMesh.position.z = front + MAT_GAP;\n matMesh.receiveShadow = true;\n group.add(matMesh);\n imageWidth = Math.max(width - 2 * mw, 1);\n imageHeight = Math.max(height - 2 * mw, 1);\n }\n\n const texture = loader.load(artwork.src, () => trackAsset(artwork.src));\n texture.colorSpace = SRGBColorSpace;\n const image = new Mesh(\n new PlaneGeometry(imageWidth, imageHeight),\n new MeshStandardMaterial({ map: texture, roughness: 0.9 }),\n );\n image.position.z = front + IMAGE_GAP;\n image.receiveShadow = true;\n image.name = \"artwork-image\";\n group.add(image);\n\n if (frame) {\n if (frame.model) loadFrameModel(group, frame.model, front);\n else group.add(buildFrameBars(frame, width, height, front));\n }\n\n return group;\n}\n\n/**\n * Build the three.js object for an `Artwork`, placing it according to its\n * `location`. Wall-hung pieces are positioned along a resolved edge (the box's\n * back resting on the wall); floating pieces use explicit coordinates. Returns\n * `null` if a wall location references an edge that no longer exists.\n */\nexport function buildArtwork(\n artwork: Artwork,\n edges: EdgeIndex,\n eyeHeight: number,\n): Object3D | null {\n const group = buildPiece(artwork);\n const depth = artwork.depth ?? DEFAULT_DEPTH;\n const [pieceWidth, pieceHeight] = artwork.size;\n const front = depth / 2;\n\n // Physical wall label (cartela) next to the piece, when declared. Placed near\n // the wall surface (the box's back), not at the protruding front face. When a\n // label exists it's the click target; otherwise the piece itself is (set below).\n const label = artwork.label ? buildLabel(artwork) : null;\n if (label) {\n const z = -depth / 2 + 0.5;\n const side = artwork.label?.side ?? \"below\";\n // Clear the frame so the cartela isn't tucked under the (proud) frame bars.\n const frameExtent = artwork.frame ? (artwork.frame.width ?? DEFAULT_FRAME_WIDTH) : 0;\n const [ox, oy] = artwork.label?.offset ?? [0, 0];\n if (side === \"below\") {\n const y = -(pieceHeight / 2 + frameExtent + LABEL_GAP + label.worldHeight / 2);\n label.object.position.set(ox, y + oy, z);\n } else {\n const sign = side === \"left\" ? -1 : 1;\n const x = sign * (pieceWidth / 2 + frameExtent + LABEL_GAP + label.worldWidth / 2);\n label.object.position.set(x + ox, oy, z);\n }\n // The label mesh is the pick target; mark only it (not the group) so the\n // piece stays non-interactive when a label is present.\n label.object.userData.pickTarget = artwork.id;\n label.object.userData.pickInfo = { width: label.worldWidth, height: label.worldHeight, frontOffset: 0.2 };\n group.add(label.object);\n } else {\n // No label: the piece is the selector. Mark the group so any of its meshes\n // resolve to this id, and outline the front image face on hover.\n group.userData.pickTarget = artwork.id;\n group.userData.pickInfo = { width: pieceWidth, height: pieceHeight, frontOffset: front + 0.6 };\n }\n\n const placement = placeArtwork(artwork, edges, eyeHeight);\n if (!placement) return null;\n group.position.set(...placement.position);\n group.rotation.y = placement.yaw;\n\n group.name = `artwork:${artwork.id}`;\n group.userData.scenographyId = artwork.id;\n // Real-world face size, consumed by the camera framing to fit the piece.\n group.userData.size = artwork.size;\n return group;\n}\n","import {\n SRGBColorSpace,\n TextureLoader,\n Vector3,\n type Mesh,\n type MeshStandardMaterial,\n type Object3D,\n type PlaneGeometry,\n type Texture,\n} from \"three\";\nimport {\n EDGES,\n EYE_HEIGHT,\n type EdgeIndex,\n type EnginePlugin,\n} from \"@scenography/engine\";\n\nimport { buildArtwork } from \"./buildArtwork\";\nimport { artworkData, type Artwork } from \"./data\";\n\nconst EMPTY_EDGES: EdgeIndex = new Map();\nconst DEFAULT_EYE_HEIGHT = 150;\nconst DEFAULT_DETAIL_DISTANCE = 450;\n/** Full-resolution textures kept on the GPU at once. */\nconst KEEP = 3;\n/** How often the camera distance is checked, in ms. */\nconst TICK = 400;\n\ntype Entry = {\n id: string;\n object: Object3D;\n material: MeshStandardMaterial;\n preview: Texture;\n nativeSrc: string;\n native?: Texture;\n};\n\n/**\n * The artwork plugin: materializes each `Artwork` as a framed box (with an\n * optional physical label), placed on a wall edge or floating. Consumes the\n * `edges` capability (from the wall plugin) to resolve wall placements and\n * `eyeHeight` for the default hang height, rebuilding when either changes.\n *\n * When an artwork carries `nativeSrc`, its `src` is treated as a preview: the\n * full texture loads as the visitor comes within `detailDistance`, keeping at\n * most `KEEP` of them on the GPU.\n */\nexport const artwork = (): EnginePlugin<Artwork> => {\n const entries = new Map<string, Entry>();\n const loaded: string[] = [];\n const loader = new TextureLoader();\n const scratch = new Vector3();\n\n const drop = (id: string): void => {\n const entry = entries.get(id);\n if (!entry?.native) return;\n entry.material.map = entry.preview;\n entry.material.needsUpdate = true;\n entry.native.dispose();\n entry.native = undefined;\n const index = loaded.indexOf(id);\n if (index !== -1) loaded.splice(index, 1);\n };\n\n const touch = (id: string): void => {\n const index = loaded.indexOf(id);\n if (index !== -1) loaded.splice(index, 1);\n loaded.push(id);\n while (loaded.length > KEEP) {\n const evicted = loaded[0];\n if (evicted === undefined) break;\n drop(evicted);\n }\n };\n\n const load = (entry: Entry): void => {\n if (entry.native) return;\n const texture = loader.load(entry.nativeSrc, () => {\n // A load that finishes after an eviction must not come back.\n if (entries.get(entry.id)?.native !== texture) return;\n entry.material.map = texture;\n entry.material.needsUpdate = true;\n });\n texture.colorSpace = SRGBColorSpace;\n entry.native = texture;\n touch(entry.id);\n };\n\n return {\n data: artworkData,\n dependsOn: [EDGES, EYE_HEIGHT],\n build: (definition, ctx) => {\n const edges = ctx.capabilities.get<EdgeIndex>(EDGES) ?? EMPTY_EDGES;\n const eyeHeight = ctx.capabilities.get<number>(EYE_HEIGHT) ?? DEFAULT_EYE_HEIGHT;\n const object = buildArtwork(definition, edges, eyeHeight);\n if (!object) return {};\n if (!definition.nativeSrc) return { object };\n\n const image = object.getObjectByName(\"artwork-image\") as\n | Mesh<PlaneGeometry, MeshStandardMaterial>\n | undefined;\n const preview = image?.material?.map;\n if (!image || !preview) return { object };\n\n const entry: Entry = {\n id: definition.id,\n object,\n material: image.material,\n preview,\n nativeSrc: definition.nativeSrc,\n };\n entries.set(entry.id, entry);\n const distance = definition.detailDistance ?? DEFAULT_DETAIL_DISTANCE;\n let elapsed = 0;\n\n return {\n object,\n update: (deltaMs) => {\n elapsed += deltaMs;\n if (elapsed < TICK) return;\n elapsed = 0;\n object.getWorldPosition(scratch);\n const dx = ctx.camera.position.x - scratch.x;\n const dz = ctx.camera.position.z - scratch.z;\n if (dx * dx + dz * dz > distance * distance) return;\n if (entry.native) touch(entry.id);\n else load(entry);\n },\n dispose: () => {\n entries.delete(entry.id);\n const index = loaded.indexOf(entry.id);\n if (index !== -1) loaded.splice(index, 1);\n entry.native?.dispose();\n entry.native = undefined;\n },\n };\n },\n };\n};\n"],"mappings":";;;;AAcA,MAAM,YAAY;AAClB,MAAM,eAAe;AACrB,MAAM,cAAc;AAEpB,MAAM,aAAa,OAAuB,KAAK,YAAY;AAC3D,MAAM,UAAU,OAAuB,UAAU,EAAE,IAAI;AAEvD,MAAM,WAAW;AACjB,MAAM,UAAU;AAChB,MAAM,qBAAqB;AAC3B,MAAM,SAAS,IAAI,eAAe;AAClC,MAAM,cAAc,IAAI,eAAe;AAKvC,MAAM,qBAA2E;CAC/E,YAAY;CACZ,MAAM;CACN,gBAAgB;AAClB;AAEA,MAAM,OAAO,QAAiC,UAC5C,OAAO,SAAS,KAAK;AACvB,MAAM,aAAa,OAA+B,QAChD,MAAM,QAAQ,MAAmB,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG;;;;;;;;AAgBvD,SAAgB,WAAW,SAAqC;CAC9D,MAAM,SAAS,QAAQ,OAAO,UAAU;CAExC,MAAM,QAAuD,CAAC;CAC9D,IAAI,IAAI,QAAQ,OAAO,KAAK,QAAQ,OAClC,MAAM,KAAK;EAAE,MAAM,QAAQ;EAAO,IAAI,OAAO,QAAQ;EAAG,MAAM;CAAK,CAAC;CACtE,MAAM,aAAa,UACjB,CACE,IAAI,QAAQ,QAAQ,IAAI,QAAQ,SAAS,KAAA,GACzC,IAAI,QAAQ,MAAM,IAAI,QAAQ,MAAM,SAAS,IAAI,KAAA,CACnD,GACA,IACF;CACA,IAAI,YAAY,MAAM,KAAK;EAAE,MAAM;EAAY,IAAI,OAAO,OAAO;EAAG,MAAM;CAAM,CAAC;CACjF,MAAM,WAAW,UACf,CACE,IAAI,QAAQ,WAAW,IAAI,QAAQ,YAAY,KAAA,GAC/C,IAAI,QAAQ,YAAY,IAAI,QAAQ,aAAa,KAAA,CACnD,GACA,IACF;CACA,IAAI,UAAU,MAAM,KAAK;EAAE,MAAM;EAAU,IAAI,OAAO,OAAO;EAAG,MAAM;CAAM,CAAC;CAE7E,MAAM,UAAU,IAAI,QAAQ,cAAc,KAAK,QAAQ,iBAAiB,KAAA;CACxE,IAAI,MAAM,WAAW,KAAK,CAAC,SAAS,OAAO;CAE3C,MAAM,SAAS,SAAS,cAAc,QAAQ;CAC9C,MAAM,MAAM,OAAO,WAAW,IAAI;CAClC,IAAI,CAAC,KAAK,OAAO;CAEjB,MAAM,QAAQ,SACZ,GAAG,KAAK,OAAO,QAAQ,MAAM,GAAG,KAAK,GAAG;CAI1C,MAAM,QAAQ,qBAAqB;CACnC,IAAI,eAAe;CACnB,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,OAAO,KAAK,IAAI;EACpB,eAAe,KAAK,IAAI,cAAc,IAAI,YAAY,KAAK,IAAI,CAAC,CAAC,KAAK;CACxE;CAEA,OAAO,QAAQ,KAAK,KAAK,gBADN,UAAU,OAAsB,KACE,IAAI,MAAM;CAC/D,MAAM,gBAAgB,MAAM,QAAQ,KAAK,MAAM,MAAM,EAAE,KAAK,aAAa,CAAC;CAC1E,OAAO,SAAS,KAAK,KAAK,KAAK,IAAI,eAAe,KAAK,IAAI,IAAI,MAAM;CAErE,IAAI,YAAY;CAChB,IAAI,SAAS,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;CAE9C,IAAI,eAAe;CACnB,IAAI,YAAY;CAChB,IAAI,IAAI;CACR,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,OAAO,KAAK,IAAI;EACpB,IAAI,SAAS,KAAK,MAAM,QAAQ,CAAC;EACjC,KAAK,KAAK,KAAK;CACjB;CAEA,IAAI,SAAS;EACX,IAAI,UAAU;EACd,IAAI,IAAI,OAAO,QAAQ,SAAS,QAAQ,GAAG,oBAAoB,QAAQ,GAAG,GAAG,KAAK,KAAK,CAAC;EACxF,IAAI,YAAY,mBAAmB,QAAQ;EAC3C,IAAI,KAAK;CACX;CAEA,MAAM,UAAU,IAAI,cAAc,MAAM;CACxC,QAAQ,aAAa;CACrB,QAAQ,YAAY;CACpB,QAAQ,YAAY;CAIpB,MAAM,QAAQ,QAAQ,OAAO,SAAS;CACtC,MAAM,aAAc,OAAO,QAAQ,cAAe;CAClD,MAAM,cAAe,OAAO,SAAS,cAAe;CACpD,MAAM,OAAO,IAAI,KACf,IAAI,cAAc,YAAY,WAAW,GACzC,IAAI,kBAAkB;EAAE,KAAK;EAAS,aAAa;CAAK,CAAC,CAC3D;CACA,KAAK,OAAO,SAAS,QAAQ;CAC7B,OAAO;EAAE,QAAQ;EAAM;EAAY;CAAY;AACjD;;;ACnIA,MAAM,cAAc;AACpB,MAAMA,kBAAgB;AAEtB,MAAM,WAAW,MAAsB,KAAK,IAAI,KAAK,IAAI,GAAG,CAAC,GAAG,CAAC;AAEjE,MAAM,gBAAgB,MACpB,OAAO,MAAM,WAAW;CAAE,OAAO;CAAG,SAAS;AAAM,IAAI;CAAE,OAAO,WAAW,CAAC;CAAG,SAAS;AAAK;;;;;;AAa/F,SAAgB,aACd,SACA,OACA,WACkB;CAClB,MAAM,MAAM,QAAQ;CACpB,IAAI,IAAI,SAAS,YACf,OAAO;EAAE,UAAU;GAAC,IAAI,MAAM;GAAI,IAAI,MAAM;GAAI,IAAI,MAAM;EAAE;EAAG,KAAK,IAAI,OAAO;CAAE;CAGnF,MAAM,OAAO,MAAM,IAAI,IAAI,IAAI;CAC/B,IAAI,CAAC,MAAM,OAAO;CAElB,MAAM,CAAC,IAAI,MAAM,KAAK;CACtB,MAAM,CAAC,IAAI,MAAM,KAAK;CACtB,MAAM,KAAK,KAAK;CAChB,MAAM,KAAK,KAAK;CAChB,MAAM,SAAS,KAAK,MAAM,IAAI,EAAE,KAAK;CACrC,MAAM,KAAK,KAAK;CAChB,MAAM,KAAK,KAAK;CAGhB,MAAM,MAAM,aAAa,IAAI,QAAQ;CACrC,MAAM,IAAI,QAAQ,IAAI,UAAU,IAAI,QAAQ,MAAM,IAAI,QAAQ,MAAM;CACpE,MAAM,KAAK,KAAK,KAAK;CACrB,MAAM,KAAK,KAAK,KAAK;CAGrB,IAAI,KAAK;CACT,IAAI,IAAI,WAAW,KAAA,GAAW;EAC5B,MAAM,IAAI,aAAa,IAAI,MAAM;EACjC,KAAK,EAAE,UAAW,EAAE,QAAQ,MAAO,KAAK,SAAS,EAAE;CACrD;CAIA,MAAM,OAAO,IAAI,SAAS,SAAS,KAAK;CACxC,MAAM,KAAK,KAAK;CAChB,MAAM,KAAK,CAAC,KAAK;CACjB,MAAM,QAAQ,QAAQ,SAASA;CAC/B,MAAM,SAAS,KAAK,YAAY,IAAI,cAAc,QAAQ;CAE1D,OAAO;EACL,UAAU;GAAC,KAAK,KAAK;GAAQ;GAAI,KAAK,KAAK;EAAM;EACjD,KAAK,KAAK,MAAM,IAAI,EAAE;CACxB;AACF;;;ACtDA,MAAM,SAAS,IAAI,cAAc;AAEjC,MAAM,gBAAgB;AACtB,MAAM,sBAAsB;AAC5B,MAAM,sBAAsB;AAC5B,MAAM,sBAAsB;AAC5B,MAAM,oBAAoB;AAC1B,MAAM,oBAAoB;AAG1B,MAAM,UAAU;AAChB,MAAM,YAAY;AAElB,MAAM,iBAAiB,UAAwE;CAC7F,MAAM,WAAW,IAAI,qBAAqB;EACxC,OAAO,MAAM,SAAS;EACtB,WAAW;CACb,CAAC;CAED,OAAO;EAAE;EAAU,UADF,MAAM,UAAU,aAAa,UAAU,MAAM,OAAO,IAAI;CAC7C;AAC9B;;AAGA,SAAS,eAAe,OAAc,OAAe,QAAgB,OAAsB;CACzF,MAAM,KAAK,MAAM,SAAS;CAC1B,MAAM,KAAK,MAAM,SAAS;CAC1B,MAAM,EAAE,UAAU,aAAa,cAAc,KAAK;CAClD,MAAM,QAAQ,IAAI,MAAM;CACxB,MAAM,IAAI,QAAQ,KAAK;CAIvB,MAAM,OAA2C;EAC/C;GAAC,QAAQ,IAAI;GAAI;GAAI;GAAG,SAAS,IAAI,KAAK;EAAC;EAC3C;GAAC,QAAQ,IAAI;GAAI;GAAI;GAAG,EAAE,SAAS,IAAI,KAAK;EAAE;EAC9C;GAAC;GAAI;GAAQ,EAAE,QAAQ,IAAI,KAAK;GAAI;EAAC;EACrC;GAAC;GAAI;GAAQ,QAAQ,IAAI,KAAK;GAAG;EAAC;CACpC;CACA,KAAK,MAAM,CAAC,IAAI,IAAI,GAAG,MAAM,MAAM;EACjC,MAAM,WAAW,IAAI,YAAY,IAAI,IAAI,EAAE;EAC3C,IAAI,UAAU;GACZ,MAAM,KAAK,SAAS,aAAa,IAAI;GACrC,IAAI,IAAI,SAAS,aAAa,OAAO,EAAE;EACzC;EACA,MAAM,MAAM,IAAI,KAAK,UAAU,QAAQ;EACvC,IAAI,SAAS,IAAI,GAAG,GAAG,CAAC;EACxB,IAAI,aAAa;EACjB,IAAI,gBAAgB;EACpB,MAAM,IAAI,GAAG;CACf;CACA,OAAO;AACT;;AAGA,SAAS,eAAe,OAAc,KAAa,OAAqB;CACtE,OAAY,2CAA2C,CAAC,MAAM,EAAE,iBAAiB;EAC/E,IAAI,WAAW,CAAC,CAAC,KAAK,MAAM,SAAS;GACnC,KAAK,MAAM,SAAS,IAAI;GACxB,KAAK,MAAM,UAAU,UAAU;IAC7B,MAAM,aAAa;IACnB,MAAM,gBAAgB;GACxB,CAAC;GACD,MAAM,IAAI,KAAK,KAAK;EACtB,CAAC;CACH,CAAC;AACH;;;;;;AAOA,SAAS,WAAW,SAAyB;CAC3C,MAAM,CAAC,OAAO,UAAU,QAAQ;CAChC,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,QAAQ,QAAQ;CACtB,MAAM,QAAQ,IAAI,MAAM;CAExB,MAAM,OAAO,IAAI,KACf,IAAI,YAAY,OAAO,QAAQ,KAAK,GACpC,IAAI,qBAAqB;EAAE,OAAO;EAAW,WAAW;CAAI,CAAC,CAC/D;CACA,KAAK,aAAa;CAClB,KAAK,gBAAgB;CACrB,MAAM,IAAI,IAAI;CAGd,MAAM,QAAQ,QAAQ;CACtB,MAAM,MAAM,OAAO;CACnB,IAAI,aAAa;CACjB,IAAI,cAAc;CAClB,IAAI,KAAK;EACP,MAAM,KAAK,IAAI,SAAS;EACxB,MAAM,UAAU,IAAI,KAClB,IAAI,cAAc,OAAO,MAAM,GAC/B,IAAI,qBAAqB;GAAE,OAAO,IAAI,SAAS;GAAmB,WAAW;EAAK,CAAC,CACrF;EACA,QAAQ,SAAS,IAAI,QAAQ;EAC7B,QAAQ,gBAAgB;EACxB,MAAM,IAAI,OAAO;EACjB,aAAa,KAAK,IAAI,QAAQ,IAAI,IAAI,CAAC;EACvC,cAAc,KAAK,IAAI,SAAS,IAAI,IAAI,CAAC;CAC3C;CAEA,MAAM,UAAU,OAAO,KAAK,QAAQ,WAAW,WAAW,QAAQ,GAAG,CAAC;CACtE,QAAQ,aAAa;CACrB,MAAM,QAAQ,IAAI,KAChB,IAAI,cAAc,YAAY,WAAW,GACzC,IAAI,qBAAqB;EAAE,KAAK;EAAS,WAAW;CAAI,CAAC,CAC3D;CACA,MAAM,SAAS,IAAI,QAAQ;CAC3B,MAAM,gBAAgB;CACtB,MAAM,OAAO;CACb,MAAM,IAAI,KAAK;CAEf,IAAI,OACF,IAAI,MAAM,OAAO,eAAe,OAAO,MAAM,OAAO,KAAK;MACpD,MAAM,IAAI,eAAe,OAAO,OAAO,QAAQ,KAAK,CAAC;CAG5D,OAAO;AACT;;;;;;;AAQA,SAAgB,aACd,SACA,OACA,WACiB;CACjB,MAAM,QAAQ,WAAW,OAAO;CAChC,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,CAAC,YAAY,eAAe,QAAQ;CAC1C,MAAM,QAAQ,QAAQ;CAKtB,MAAM,QAAQ,QAAQ,QAAQ,WAAW,OAAO,IAAI;CACpD,IAAI,OAAO;EACT,MAAM,IAAI,CAAC,QAAQ,IAAI;EACvB,MAAM,OAAO,QAAQ,OAAO,QAAQ;EAEpC,MAAM,cAAc,QAAQ,QAAS,QAAQ,MAAM,SAAS,sBAAuB;EACnF,MAAM,CAAC,IAAI,MAAM,QAAQ,OAAO,UAAU,CAAC,GAAG,CAAC;EAC/C,IAAI,SAAS,SAAS;GACpB,MAAM,IAAI,EAAE,cAAc,IAAI,cAAA,IAA0B,MAAM,cAAc;GAC5E,MAAM,OAAO,SAAS,IAAI,IAAI,IAAI,IAAI,CAAC;EACzC,OAAO;GAEL,MAAM,KADO,SAAS,SAAS,KAAK,MAClB,aAAa,IAAI,cAAA,IAA0B,MAAM,aAAa;GAChF,MAAM,OAAO,SAAS,IAAI,IAAI,IAAI,IAAI,CAAC;EACzC;EAGA,MAAM,OAAO,SAAS,aAAa,QAAQ;EAC3C,MAAM,OAAO,SAAS,WAAW;GAAE,OAAO,MAAM;GAAY,QAAQ,MAAM;GAAa,aAAa;EAAI;EACxG,MAAM,IAAI,MAAM,MAAM;CACxB,OAAO;EAGL,MAAM,SAAS,aAAa,QAAQ;EACpC,MAAM,SAAS,WAAW;GAAE,OAAO;GAAY,QAAQ;GAAa,aAAa,QAAQ;EAAI;CAC/F;CAEA,MAAM,YAAY,aAAa,SAAS,OAAO,SAAS;CACxD,IAAI,CAAC,WAAW,OAAO;CACvB,MAAM,SAAS,IAAI,GAAG,UAAU,QAAQ;CACxC,MAAM,SAAS,IAAI,UAAU;CAE7B,MAAM,OAAO,WAAW,QAAQ;CAChC,MAAM,SAAS,gBAAgB,QAAQ;CAEvC,MAAM,SAAS,OAAO,QAAQ;CAC9B,OAAO;AACT;;;AC/KA,MAAM,8BAAyB,IAAI,IAAI;AACvC,MAAM,qBAAqB;AAC3B,MAAM,0BAA0B;;AAEhC,MAAM,OAAO;;AAEb,MAAM,OAAO;;;;;;;;;;;AAqBb,MAAa,gBAAuC;CAClD,MAAM,0BAAU,IAAI,IAAmB;CACvC,MAAM,SAAmB,CAAC;CAC1B,MAAM,SAAS,IAAI,cAAc;CACjC,MAAM,UAAU,IAAI,QAAQ;CAE5B,MAAM,QAAQ,OAAqB;EACjC,MAAM,QAAQ,QAAQ,IAAI,EAAE;EAC5B,IAAI,CAAC,OAAO,QAAQ;EACpB,MAAM,SAAS,MAAM,MAAM;EAC3B,MAAM,SAAS,cAAc;EAC7B,MAAM,OAAO,QAAQ;EACrB,MAAM,SAAS,KAAA;EACf,MAAM,QAAQ,OAAO,QAAQ,EAAE;EAC/B,IAAI,UAAU,IAAI,OAAO,OAAO,OAAO,CAAC;CAC1C;CAEA,MAAM,SAAS,OAAqB;EAClC,MAAM,QAAQ,OAAO,QAAQ,EAAE;EAC/B,IAAI,UAAU,IAAI,OAAO,OAAO,OAAO,CAAC;EACxC,OAAO,KAAK,EAAE;EACd,OAAO,OAAO,SAAS,MAAM;GAC3B,MAAM,UAAU,OAAO;GACvB,IAAI,YAAY,KAAA,GAAW;GAC3B,KAAK,OAAO;EACd;CACF;CAEA,MAAM,QAAQ,UAAuB;EACnC,IAAI,MAAM,QAAQ;EAClB,MAAM,UAAU,OAAO,KAAK,MAAM,iBAAiB;GAEjD,IAAI,QAAQ,IAAI,MAAM,EAAE,CAAC,EAAE,WAAW,SAAS;GAC/C,MAAM,SAAS,MAAM;GACrB,MAAM,SAAS,cAAc;EAC/B,CAAC;EACD,QAAQ,aAAa;EACrB,MAAM,SAAS;EACf,MAAM,MAAM,EAAE;CAChB;CAEA,OAAO;EACL,MAAM;EACN,WAAW,CAAC,OAAO,UAAU;EAC7B,QAAQ,YAAY,QAAQ;GAG1B,MAAM,SAAS,aAAa,YAFd,IAAI,aAAa,IAAe,KAAK,KAAK,aACtC,IAAI,aAAa,IAAY,UAAU,KAAK,kBACN;GACxD,IAAI,CAAC,QAAQ,OAAO,CAAC;GACrB,IAAI,CAAC,WAAW,WAAW,OAAO,EAAE,OAAO;GAE3C,MAAM,QAAQ,OAAO,gBAAgB,eAAe;GAGpD,MAAM,UAAU,OAAO,UAAU;GACjC,IAAI,CAAC,SAAS,CAAC,SAAS,OAAO,EAAE,OAAO;GAExC,MAAM,QAAe;IACnB,IAAI,WAAW;IACf;IACA,UAAU,MAAM;IAChB;IACA,WAAW,WAAW;GACxB;GACA,QAAQ,IAAI,MAAM,IAAI,KAAK;GAC3B,MAAM,WAAW,WAAW,kBAAkB;GAC9C,IAAI,UAAU;GAEd,OAAO;IACL;IACA,SAAS,YAAY;KACnB,WAAW;KACX,IAAI,UAAU,MAAM;KACpB,UAAU;KACV,OAAO,iBAAiB,OAAO;KAC/B,MAAM,KAAK,IAAI,OAAO,SAAS,IAAI,QAAQ;KAC3C,MAAM,KAAK,IAAI,OAAO,SAAS,IAAI,QAAQ;KAC3C,IAAI,KAAK,KAAK,KAAK,KAAK,WAAW,UAAU;KAC7C,IAAI,MAAM,QAAQ,MAAM,MAAM,EAAE;UAC3B,KAAK,KAAK;IACjB;IACA,eAAe;KACb,QAAQ,OAAO,MAAM,EAAE;KACvB,MAAM,QAAQ,OAAO,QAAQ,MAAM,EAAE;KACrC,IAAI,UAAU,IAAI,OAAO,OAAO,OAAO,CAAC;KACxC,MAAM,QAAQ,QAAQ;KACtB,MAAM,SAAS,KAAA;IACjB;GACF;EACF;CACF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scenography/plugin-artwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Artwork plugin for scenography: framed images hung on wall edges or floating, with an optional physical label. Data half importable without three.js.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -33,17 +33,17 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@scenography/plugin-wall": "0.
|
|
37
|
-
"@scenography/scene": "0.
|
|
36
|
+
"@scenography/plugin-wall": "0.7.0",
|
|
37
|
+
"@scenography/scene": "0.7.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/three": "^0.160.0",
|
|
41
41
|
"three": "^0.160.0",
|
|
42
|
-
"@scenography/engine": "0.
|
|
42
|
+
"@scenography/engine": "0.7.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"three": ">=0.160.0",
|
|
46
|
-
"@scenography/engine": "0.
|
|
46
|
+
"@scenography/engine": "0.7.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsdown",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"data-BsI0PbuK.mjs","names":[],"sources":["../src/data/artwork.ts","../src/data/validate.ts","../src/data/plugin.ts"],"sourcesContent":["import type { Measure, SurfaceTexture, Vec2, Vec3 } from \"@scenography/scene\";\n\n/**\n * Where an artwork is placed. Extensible via `type`; today a piece either hangs\n * on a named wall edge or floats freely. Future element types (floor, ceiling…)\n * add new variants here.\n */\nexport type ArtworkLocation =\n | {\n type: \"wall\";\n /** Name of the `NamedEdge` to hang on. */\n edge: string;\n /** Position along the edge: a number is units from the edge start, `'50%'` is a fraction. */\n position: Measure;\n /**\n * Vertical placement: a number is an absolute world Y, `'50%'` is a\n * fraction of the wall height. Omitted defaults to the camera's eye height.\n */\n height?: Measure;\n /**\n * Which face of the wall the piece shows on, relative to the edge\n * direction. `'right'` (default) is the right-hand side when walking the\n * edge from its start vertex to its end vertex.\n */\n side?: \"right\" | \"left\";\n }\n | {\n type: \"floating\";\n /** World position `[x, y, z]`. */\n point: Vec3;\n /** Y-axis rotation in radians. Defaults to `0`. */\n yaw?: number;\n };\n\n/**\n * A passepartout (mat) sitting between the frame and the image: a flat border\n * that insets the image. Only meaningful when the artwork has a `frame`.\n */\nexport interface Passepartout {\n /** Mat border width around the image, in world units. Defaults to `6`. */\n width?: number;\n /** Mat color (CSS). Defaults to an off-white. */\n color?: string;\n}\n\n/**\n * A frame around an artwork. By default it's four flat bars rendered with a\n * solid `color` or a `texture` (e.g. wood). For an ornate, sculpted frame, point\n * `model` at an external glTF/GLB instead. A `passepartout` mat can sit between\n * the frame and the image.\n */\nexport interface Frame {\n /** Border width (how wide the frame face is), in world units. Defaults to `5`. */\n width?: number;\n /** How far the frame stands proud of the artwork face, in world units. Defaults to `3`. */\n depth?: number;\n /** Frame color (CSS). Defaults to a dark tone. Ignored when `texture` or `model` is set. */\n color?: string;\n /** Surface texture (e.g. wood); takes visual precedence over `color`. */\n texture?: SurfaceTexture;\n /** External glTF/GLB model for an ornate frame; replaces the generated bars. */\n model?: string;\n /** Passepartout mat between the frame and the image. */\n passepartout?: Passepartout;\n}\n\n/**\n * Commercial status of a piece. Gallery convention: a colored dot signals\n * availability. The dot's color is presentation (decided by the renderer), not\n * data — this is the state it derives from.\n */\nexport type Availability = \"for-sale\" | \"sold\" | \"not-for-sale\";\n\n/**\n * The fields a label/info card can show, identified by name. A `fields` array\n * toggles which are visible; the render order is fixed (it is not the array\n * order): title, then author/year, then technique + dimensions on one line, with\n * availability shown as a corner dot. Shared by the physical label (engine) and\n * the overlay info card.\n */\nexport type ArtworkField = \"title\" | \"author\" | \"year\" | \"technique\" | \"dimensions\" | \"availability\";\n\n/** Default visibility when a label/card doesn't specify its own `fields`. */\nexport const DEFAULT_ARTWORK_FIELDS: readonly ArtworkField[] = [\n \"title\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n];\n\n/**\n * A physical wall label (cartela) materialized next to the piece. Its presence\n * is the opt-in; the engine renders it from the artwork's own data.\n */\nexport interface ArtworkLabel {\n /** Where the label sits relative to the piece. Defaults to `'below'`. */\n side?: \"below\" | \"left\" | \"right\";\n /** Which fields to show. Defaults to `DEFAULT_ARTWORK_FIELDS`. */\n fields?: ArtworkField[];\n /**\n * Size multiplier over the real physical size (24pt title etc). Defaults to\n * `1` (true to life), but a real cartela is only a few cm tall — tiny and hard\n * to read/click at gallery scale — so bump this (e.g. `3`) for legibility.\n */\n scale?: number;\n /**\n * Fine-tune the placement with an `[x, y]` shift in world units, added on top\n * of the `side` position (the frame is already cleared). `x` runs along the\n * wall (right is positive), `y` is vertical (up is positive).\n */\n offset?: Vec2;\n}\n\n/** A framed image placed somewhere in the scene. */\nexport interface Artwork {\n id: string;\n location: ArtworkLocation;\n /** Image URL/path to display. */\n src: string;\n /** `[width, height]` of the artwork, in world units. */\n size: Vec2;\n /**\n * Box depth (volume) in world units: the piece is a box with the image on the\n * front and white sides/back. Defaults to `2`.\n */\n depth?: number;\n /** Optional frame around the artwork. */\n frame?: Frame;\n title?: string;\n technique?: string;\n /** Author / artist name. */\n author?: string;\n /** Year the piece was made. */\n year?: number;\n /**\n * Real-world dimensions for display, e.g. `'92 × 73 cm'`. Distinct from `size`,\n * which is in world units.\n */\n dimensions?: string;\n /** Commercial status; drives the availability dot. */\n availability?: Availability;\n /** Long-form caption / catalog entry, shown in the expanded info panel. */\n description?: string;\n /**\n * External URL for the piece (a catalog page, or a marketplace listing when\n * it's for sale). Surfaced as a link in the expanded info panel.\n */\n href?: string;\n /** When present, a physical wall label is materialized next to the piece. */\n label?: ArtworkLabel;\n /**\n * Max distance (world units) from which the piece can be clicked to open its\n * info panel. A gate so far-away pieces aren't interactive. Absent = no gate.\n */\n infoRadius?: number;\n}\n\ndeclare module \"@scenography/scene\" {\n interface Scene {\n /** Framed images placed in the space — provided by `@scenography/plugin-artwork`. */\n artworks?: Artwork[];\n }\n}\n","import {\n eachPropIs,\n existsProp,\n fail,\n isFiniteNumber,\n isIntegerNumber,\n isMeasure,\n isNonNegativeNumber,\n isObject,\n isOneOf,\n isPositiveNumber,\n isString,\n isVec2,\n isVec3,\n propIs,\n validate,\n when,\n type SceneContext,\n type ValidationResult,\n} from \"@scenography/scene\";\nimport { allEdgeNames, type Wall } from \"@scenography/plugin-wall/data\";\n\nimport type { Artwork, ArtworkField, Availability } from \"./artwork\";\n\nconst AVAILABILITY: ReadonlySet<Availability> = new Set<Availability>([\n \"for-sale\",\n \"sold\",\n \"not-for-sale\",\n]);\nconst ARTWORK_FIELDS: ReadonlySet<ArtworkField> = new Set<ArtworkField>([\n \"title\",\n \"author\",\n \"year\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n]);\nconst LABEL_SIDES: ReadonlySet<string> = new Set([\"below\", \"left\", \"right\"]);\nconst LOCATION_TYPES: ReadonlySet<string> = new Set([\"wall\", \"floating\"]);\n\nconst locationOfType =\n (type: string) =>\n (artwork: Artwork): boolean =>\n isObject(artwork.location) && artwork.location.type === type;\n\n/**\n * Validate one artwork. `edgeNames` is the set of wall-edge names in the scene,\n * used to check `location.edge` references. Returns every problem found, in\n * order, with paths relative to the artwork (the caller scopes them).\n */\nexport const validateArtwork = (artwork: Artwork, edgeNames: Set<string>): ValidationResult =>\n validate<Artwork>([\n existsProp(\"id\", \"Artwork is missing an id.\"),\n existsProp(\"src\", \"Artwork is missing a src.\"),\n existsProp(\"size\", \"Artwork is missing a size.\"),\n propIs(\"size\", isVec2, \"size must be [width, height].\"),\n propIs(\"depth\", isNonNegativeNumber, \"depth must be a non-negative number.\"),\n propIs(\"year\", isIntegerNumber, \"year must be an integer.\"),\n propIs(\"href\", isString, \"href must be a string.\"),\n propIs(\n \"availability\",\n isOneOf(AVAILABILITY),\n \"availability must be 'for-sale', 'sold' or 'not-for-sale'.\",\n ),\n propIs(\"infoRadius\", isPositiveNumber, \"infoRadius must be a positive number.\"),\n propIs(\"label.side\", isOneOf(LABEL_SIDES), \"side must be 'below', 'left' or 'right'.\"),\n propIs(\"label.fields\", Array.isArray, \"fields must be an array.\"),\n eachPropIs(\"label.fields\", isOneOf(ARTWORK_FIELDS), (field) => `unknown field \"${String(field)}\".`),\n propIs(\"label.scale\", isPositiveNumber, \"scale must be a positive number.\"),\n propIs(\"label.offset\", isVec2, \"offset must be [x, y].\"),\n propIs(\"frame.width\", isFiniteNumber, \"width must be a finite number.\"),\n propIs(\"frame.depth\", isFiniteNumber, \"depth must be a finite number.\"),\n when(\n (a: Artwork) => a.frame?.texture !== undefined,\n existsProp(\"frame.texture.src\", \"frame texture is missing a src.\"),\n ),\n propIs(\n \"frame.passepartout.width\",\n isFiniteNumber,\n \"passepartout width must be a finite number.\",\n ),\n existsProp(\"location\", \"Artwork is missing a location.\"),\n propIs(\"location\", isObject, \"location must be an object.\"),\n when(\n locationOfType(\"wall\"),\n existsProp(\"location.edge\"),\n propIs(\n \"location.edge\",\n (edge) => isString(edge) && edgeNames.has(edge),\n (edge) => `references unknown edge \"${String(edge)}\".`,\n ),\n existsProp(\"location.position\"),\n propIs(\"location.position\", isMeasure, \"position must be a number or a percentage string.\"),\n propIs(\"location.height\", isMeasure, \"height must be a number or a percentage string.\"),\n ),\n when(\n locationOfType(\"floating\"),\n existsProp(\"location.point\"),\n propIs(\"location.point\", isVec3, \"point must be [x, y, z].\"),\n ),\n when(\n (a: Artwork) => isObject(a.location) && !isOneOf(LOCATION_TYPES)(a.location.type),\n fail(\"location.type\", \"unknown location type.\"),\n ),\n ])(artwork);\n\n/**\n * Validate an artwork against its scene: resolves the set of valid wall-edge\n * names from the scene's walls (the wall plugin's data half) so `location.edge`\n * references are checked. Floating pieces don't need any wall.\n */\nexport const validateArtworkInScene = (artwork: Artwork, ctx: SceneContext): ValidationResult => {\n const walls = (ctx.scene as { walls?: Wall[] }).walls ?? [];\n return validateArtwork(artwork, new Set(allEdgeNames(walls)));\n};\n","import type { DataPlugin } from \"@scenography/scene\";\n\nimport { validateArtworkInScene } from \"./validate\";\nimport type { Artwork } from \"./artwork\";\n\n/**\n * The data half of the artwork plugin: owns the `artworks` scene key (a\n * collection) and validates each piece — including that a wall-hung piece\n * references a real wall edge. Pure — no three.js.\n */\nexport const artworkData: DataPlugin<Artwork> = {\n kind: \"artworks\",\n cardinality: \"many\",\n validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx),\n};\n"],"mappings":";;;;AAmFA,MAAa,yBAAkD;CAC7D;CACA;CACA;CACA;AACF;;;AChEA,MAAM,eAA0C,IAAI,IAAkB;CACpE;CACA;CACA;AACF,CAAC;AACD,MAAM,iBAA4C,IAAI,IAAkB;CACtE;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;AACD,MAAM,cAAmC,IAAI,IAAI;CAAC;CAAS;CAAQ;AAAO,CAAC;AAC3E,MAAM,iBAAsC,IAAI,IAAI,CAAC,QAAQ,UAAU,CAAC;AAExE,MAAM,kBACH,UACA,YACC,SAAS,QAAQ,QAAQ,KAAK,QAAQ,SAAS,SAAS;;;;;;AAO5D,MAAa,mBAAmB,SAAkB,cAChD,SAAkB;CAChB,WAAW,MAAM,2BAA2B;CAC5C,WAAW,OAAO,2BAA2B;CAC7C,WAAW,QAAQ,4BAA4B;CAC/C,OAAO,QAAQ,QAAQ,+BAA+B;CACtD,OAAO,SAAS,qBAAqB,sCAAsC;CAC3E,OAAO,QAAQ,iBAAiB,0BAA0B;CAC1D,OAAO,QAAQ,UAAU,wBAAwB;CACjD,OACE,gBACA,QAAQ,YAAY,GACpB,4DACF;CACA,OAAO,cAAc,kBAAkB,uCAAuC;CAC9E,OAAO,cAAc,QAAQ,WAAW,GAAG,0CAA0C;CACrF,OAAO,gBAAgB,MAAM,SAAS,0BAA0B;CAChE,WAAW,gBAAgB,QAAQ,cAAc,IAAI,UAAU,kBAAkB,OAAO,KAAK,EAAE,GAAG;CAClG,OAAO,eAAe,kBAAkB,kCAAkC;CAC1E,OAAO,gBAAgB,QAAQ,wBAAwB;CACvD,OAAO,eAAe,gBAAgB,gCAAgC;CACtE,OAAO,eAAe,gBAAgB,gCAAgC;CACtE,MACG,MAAe,EAAE,OAAO,YAAY,KAAA,GACrC,WAAW,qBAAqB,iCAAiC,CACnE;CACA,OACE,4BACA,gBACA,6CACF;CACA,WAAW,YAAY,gCAAgC;CACvD,OAAO,YAAY,UAAU,6BAA6B;CAC1D,KACE,eAAe,MAAM,GACrB,WAAW,eAAe,GAC1B,OACE,kBACC,SAAS,SAAS,IAAI,KAAK,UAAU,IAAI,IAAI,IAC7C,SAAS,4BAA4B,OAAO,IAAI,EAAE,GACrD,GACA,WAAW,mBAAmB,GAC9B,OAAO,qBAAqB,WAAW,mDAAmD,GAC1F,OAAO,mBAAmB,WAAW,iDAAiD,CACxF;CACA,KACE,eAAe,UAAU,GACzB,WAAW,gBAAgB,GAC3B,OAAO,kBAAkB,QAAQ,0BAA0B,CAC7D;CACA,MACG,MAAe,SAAS,EAAE,QAAQ,KAAK,CAAC,QAAQ,cAAc,CAAC,CAAC,EAAE,SAAS,IAAI,GAChF,KAAK,iBAAiB,wBAAwB,CAChD;AACF,CAAC,CAAC,CAAC,OAAO;;;;;;AAOZ,MAAa,0BAA0B,SAAkB,QAAwC;CAC/F,MAAM,QAAS,IAAI,MAA6B,SAAS,CAAC;CAC1D,OAAO,gBAAgB,SAAS,IAAI,IAAI,aAAa,KAAK,CAAC,CAAC;AAC9D;;;;;;;;ACxGA,MAAa,cAAmC;CAC9C,MAAM;CACN,aAAa;CACb,WAAW,SAAS,QAAQ,uBAAuB,SAAS,GAAG;AACjE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"data-WcCPqQ_k.cjs","names":["isVec2","isNonNegativeNumber","isIntegerNumber","isString","isPositiveNumber","isFiniteNumber","isObject","isMeasure","isVec3"],"sources":["../src/data/artwork.ts","../src/data/validate.ts","../src/data/plugin.ts"],"sourcesContent":["import type { Measure, SurfaceTexture, Vec2, Vec3 } from \"@scenography/scene\";\n\n/**\n * Where an artwork is placed. Extensible via `type`; today a piece either hangs\n * on a named wall edge or floats freely. Future element types (floor, ceiling…)\n * add new variants here.\n */\nexport type ArtworkLocation =\n | {\n type: \"wall\";\n /** Name of the `NamedEdge` to hang on. */\n edge: string;\n /** Position along the edge: a number is units from the edge start, `'50%'` is a fraction. */\n position: Measure;\n /**\n * Vertical placement: a number is an absolute world Y, `'50%'` is a\n * fraction of the wall height. Omitted defaults to the camera's eye height.\n */\n height?: Measure;\n /**\n * Which face of the wall the piece shows on, relative to the edge\n * direction. `'right'` (default) is the right-hand side when walking the\n * edge from its start vertex to its end vertex.\n */\n side?: \"right\" | \"left\";\n }\n | {\n type: \"floating\";\n /** World position `[x, y, z]`. */\n point: Vec3;\n /** Y-axis rotation in radians. Defaults to `0`. */\n yaw?: number;\n };\n\n/**\n * A passepartout (mat) sitting between the frame and the image: a flat border\n * that insets the image. Only meaningful when the artwork has a `frame`.\n */\nexport interface Passepartout {\n /** Mat border width around the image, in world units. Defaults to `6`. */\n width?: number;\n /** Mat color (CSS). Defaults to an off-white. */\n color?: string;\n}\n\n/**\n * A frame around an artwork. By default it's four flat bars rendered with a\n * solid `color` or a `texture` (e.g. wood). For an ornate, sculpted frame, point\n * `model` at an external glTF/GLB instead. A `passepartout` mat can sit between\n * the frame and the image.\n */\nexport interface Frame {\n /** Border width (how wide the frame face is), in world units. Defaults to `5`. */\n width?: number;\n /** How far the frame stands proud of the artwork face, in world units. Defaults to `3`. */\n depth?: number;\n /** Frame color (CSS). Defaults to a dark tone. Ignored when `texture` or `model` is set. */\n color?: string;\n /** Surface texture (e.g. wood); takes visual precedence over `color`. */\n texture?: SurfaceTexture;\n /** External glTF/GLB model for an ornate frame; replaces the generated bars. */\n model?: string;\n /** Passepartout mat between the frame and the image. */\n passepartout?: Passepartout;\n}\n\n/**\n * Commercial status of a piece. Gallery convention: a colored dot signals\n * availability. The dot's color is presentation (decided by the renderer), not\n * data — this is the state it derives from.\n */\nexport type Availability = \"for-sale\" | \"sold\" | \"not-for-sale\";\n\n/**\n * The fields a label/info card can show, identified by name. A `fields` array\n * toggles which are visible; the render order is fixed (it is not the array\n * order): title, then author/year, then technique + dimensions on one line, with\n * availability shown as a corner dot. Shared by the physical label (engine) and\n * the overlay info card.\n */\nexport type ArtworkField = \"title\" | \"author\" | \"year\" | \"technique\" | \"dimensions\" | \"availability\";\n\n/** Default visibility when a label/card doesn't specify its own `fields`. */\nexport const DEFAULT_ARTWORK_FIELDS: readonly ArtworkField[] = [\n \"title\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n];\n\n/**\n * A physical wall label (cartela) materialized next to the piece. Its presence\n * is the opt-in; the engine renders it from the artwork's own data.\n */\nexport interface ArtworkLabel {\n /** Where the label sits relative to the piece. Defaults to `'below'`. */\n side?: \"below\" | \"left\" | \"right\";\n /** Which fields to show. Defaults to `DEFAULT_ARTWORK_FIELDS`. */\n fields?: ArtworkField[];\n /**\n * Size multiplier over the real physical size (24pt title etc). Defaults to\n * `1` (true to life), but a real cartela is only a few cm tall — tiny and hard\n * to read/click at gallery scale — so bump this (e.g. `3`) for legibility.\n */\n scale?: number;\n /**\n * Fine-tune the placement with an `[x, y]` shift in world units, added on top\n * of the `side` position (the frame is already cleared). `x` runs along the\n * wall (right is positive), `y` is vertical (up is positive).\n */\n offset?: Vec2;\n}\n\n/** A framed image placed somewhere in the scene. */\nexport interface Artwork {\n id: string;\n location: ArtworkLocation;\n /** Image URL/path to display. */\n src: string;\n /** `[width, height]` of the artwork, in world units. */\n size: Vec2;\n /**\n * Box depth (volume) in world units: the piece is a box with the image on the\n * front and white sides/back. Defaults to `2`.\n */\n depth?: number;\n /** Optional frame around the artwork. */\n frame?: Frame;\n title?: string;\n technique?: string;\n /** Author / artist name. */\n author?: string;\n /** Year the piece was made. */\n year?: number;\n /**\n * Real-world dimensions for display, e.g. `'92 × 73 cm'`. Distinct from `size`,\n * which is in world units.\n */\n dimensions?: string;\n /** Commercial status; drives the availability dot. */\n availability?: Availability;\n /** Long-form caption / catalog entry, shown in the expanded info panel. */\n description?: string;\n /**\n * External URL for the piece (a catalog page, or a marketplace listing when\n * it's for sale). Surfaced as a link in the expanded info panel.\n */\n href?: string;\n /** When present, a physical wall label is materialized next to the piece. */\n label?: ArtworkLabel;\n /**\n * Max distance (world units) from which the piece can be clicked to open its\n * info panel. A gate so far-away pieces aren't interactive. Absent = no gate.\n */\n infoRadius?: number;\n}\n\ndeclare module \"@scenography/scene\" {\n interface Scene {\n /** Framed images placed in the space — provided by `@scenography/plugin-artwork`. */\n artworks?: Artwork[];\n }\n}\n","import {\n eachPropIs,\n existsProp,\n fail,\n isFiniteNumber,\n isIntegerNumber,\n isMeasure,\n isNonNegativeNumber,\n isObject,\n isOneOf,\n isPositiveNumber,\n isString,\n isVec2,\n isVec3,\n propIs,\n validate,\n when,\n type SceneContext,\n type ValidationResult,\n} from \"@scenography/scene\";\nimport { allEdgeNames, type Wall } from \"@scenography/plugin-wall/data\";\n\nimport type { Artwork, ArtworkField, Availability } from \"./artwork\";\n\nconst AVAILABILITY: ReadonlySet<Availability> = new Set<Availability>([\n \"for-sale\",\n \"sold\",\n \"not-for-sale\",\n]);\nconst ARTWORK_FIELDS: ReadonlySet<ArtworkField> = new Set<ArtworkField>([\n \"title\",\n \"author\",\n \"year\",\n \"technique\",\n \"dimensions\",\n \"availability\",\n]);\nconst LABEL_SIDES: ReadonlySet<string> = new Set([\"below\", \"left\", \"right\"]);\nconst LOCATION_TYPES: ReadonlySet<string> = new Set([\"wall\", \"floating\"]);\n\nconst locationOfType =\n (type: string) =>\n (artwork: Artwork): boolean =>\n isObject(artwork.location) && artwork.location.type === type;\n\n/**\n * Validate one artwork. `edgeNames` is the set of wall-edge names in the scene,\n * used to check `location.edge` references. Returns every problem found, in\n * order, with paths relative to the artwork (the caller scopes them).\n */\nexport const validateArtwork = (artwork: Artwork, edgeNames: Set<string>): ValidationResult =>\n validate<Artwork>([\n existsProp(\"id\", \"Artwork is missing an id.\"),\n existsProp(\"src\", \"Artwork is missing a src.\"),\n existsProp(\"size\", \"Artwork is missing a size.\"),\n propIs(\"size\", isVec2, \"size must be [width, height].\"),\n propIs(\"depth\", isNonNegativeNumber, \"depth must be a non-negative number.\"),\n propIs(\"year\", isIntegerNumber, \"year must be an integer.\"),\n propIs(\"href\", isString, \"href must be a string.\"),\n propIs(\n \"availability\",\n isOneOf(AVAILABILITY),\n \"availability must be 'for-sale', 'sold' or 'not-for-sale'.\",\n ),\n propIs(\"infoRadius\", isPositiveNumber, \"infoRadius must be a positive number.\"),\n propIs(\"label.side\", isOneOf(LABEL_SIDES), \"side must be 'below', 'left' or 'right'.\"),\n propIs(\"label.fields\", Array.isArray, \"fields must be an array.\"),\n eachPropIs(\"label.fields\", isOneOf(ARTWORK_FIELDS), (field) => `unknown field \"${String(field)}\".`),\n propIs(\"label.scale\", isPositiveNumber, \"scale must be a positive number.\"),\n propIs(\"label.offset\", isVec2, \"offset must be [x, y].\"),\n propIs(\"frame.width\", isFiniteNumber, \"width must be a finite number.\"),\n propIs(\"frame.depth\", isFiniteNumber, \"depth must be a finite number.\"),\n when(\n (a: Artwork) => a.frame?.texture !== undefined,\n existsProp(\"frame.texture.src\", \"frame texture is missing a src.\"),\n ),\n propIs(\n \"frame.passepartout.width\",\n isFiniteNumber,\n \"passepartout width must be a finite number.\",\n ),\n existsProp(\"location\", \"Artwork is missing a location.\"),\n propIs(\"location\", isObject, \"location must be an object.\"),\n when(\n locationOfType(\"wall\"),\n existsProp(\"location.edge\"),\n propIs(\n \"location.edge\",\n (edge) => isString(edge) && edgeNames.has(edge),\n (edge) => `references unknown edge \"${String(edge)}\".`,\n ),\n existsProp(\"location.position\"),\n propIs(\"location.position\", isMeasure, \"position must be a number or a percentage string.\"),\n propIs(\"location.height\", isMeasure, \"height must be a number or a percentage string.\"),\n ),\n when(\n locationOfType(\"floating\"),\n existsProp(\"location.point\"),\n propIs(\"location.point\", isVec3, \"point must be [x, y, z].\"),\n ),\n when(\n (a: Artwork) => isObject(a.location) && !isOneOf(LOCATION_TYPES)(a.location.type),\n fail(\"location.type\", \"unknown location type.\"),\n ),\n ])(artwork);\n\n/**\n * Validate an artwork against its scene: resolves the set of valid wall-edge\n * names from the scene's walls (the wall plugin's data half) so `location.edge`\n * references are checked. Floating pieces don't need any wall.\n */\nexport const validateArtworkInScene = (artwork: Artwork, ctx: SceneContext): ValidationResult => {\n const walls = (ctx.scene as { walls?: Wall[] }).walls ?? [];\n return validateArtwork(artwork, new Set(allEdgeNames(walls)));\n};\n","import type { DataPlugin } from \"@scenography/scene\";\n\nimport { validateArtworkInScene } from \"./validate\";\nimport type { Artwork } from \"./artwork\";\n\n/**\n * The data half of the artwork plugin: owns the `artworks` scene key (a\n * collection) and validates each piece — including that a wall-hung piece\n * references a real wall edge. Pure — no three.js.\n */\nexport const artworkData: DataPlugin<Artwork> = {\n kind: \"artworks\",\n cardinality: \"many\",\n validate: (artwork, ctx) => validateArtworkInScene(artwork, ctx),\n};\n"],"mappings":";;;;AAmFA,MAAa,yBAAkD;CAC7D;CACA;CACA;CACA;AACF;;;AChEA,MAAM,eAA0C,IAAI,IAAkB;CACpE;CACA;CACA;AACF,CAAC;AACD,MAAM,iBAA4C,IAAI,IAAkB;CACtE;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;AACD,MAAM,cAAmC,IAAI,IAAI;CAAC;CAAS;CAAQ;AAAO,CAAC;AAC3E,MAAM,iBAAsC,IAAI,IAAI,CAAC,QAAQ,UAAU,CAAC;AAExE,MAAM,kBACH,UACA,aAAA,GAAA,mBAAA,SAAA,CACU,QAAQ,QAAQ,KAAK,QAAQ,SAAS,SAAS;;;;;;AAO5D,MAAa,mBAAmB,SAAkB,eAAA,GAAA,mBAAA,SAAA,CAC9B;oCACL,MAAM,2BAA2B;oCACjC,OAAO,2BAA2B;oCAClC,QAAQ,4BAA4B;gCACxC,QAAQA,mBAAAA,QAAQ,+BAA+B;gCAC/C,SAASC,mBAAAA,qBAAqB,sCAAsC;gCACpE,QAAQC,mBAAAA,iBAAiB,0BAA0B;gCACnD,QAAQC,mBAAAA,UAAU,wBAAwB;gCAE/C,iBAAA,GAAA,mBAAA,QAAA,CACQ,YAAY,GACpB,4DACF;gCACO,cAAcC,mBAAAA,kBAAkB,uCAAuC;gCACvE,eAAA,GAAA,mBAAA,QAAA,CAAsB,WAAW,GAAG,0CAA0C;gCAC9E,gBAAgB,MAAM,SAAS,0BAA0B;oCACrD,iBAAA,GAAA,mBAAA,QAAA,CAAwB,cAAc,IAAI,UAAU,kBAAkB,OAAO,KAAK,EAAE,GAAG;gCAC3F,eAAeA,mBAAAA,kBAAkB,kCAAkC;gCACnE,gBAAgBJ,mBAAAA,QAAQ,wBAAwB;gCAChD,eAAeK,mBAAAA,gBAAgB,gCAAgC;gCAC/D,eAAeA,mBAAAA,gBAAgB,gCAAgC;+BAEnE,MAAe,EAAE,OAAO,YAAY,KAAA,IAAA,GAAA,mBAAA,WAAA,CAC1B,qBAAqB,iCAAiC,CACnE;gCAEE,4BACAA,mBAAAA,gBACA,6CACF;oCACW,YAAY,gCAAgC;gCAChD,YAAYC,mBAAAA,UAAU,6BAA6B;8BAExD,eAAe,MAAM,IAAA,GAAA,mBAAA,WAAA,CACV,eAAe,IAAA,GAAA,mBAAA,OAAA,CAExB,kBACC,UAAA,GAAA,mBAAA,SAAA,CAAkB,IAAI,KAAK,UAAU,IAAI,IAAI,IAC7C,SAAS,4BAA4B,OAAO,IAAI,EAAE,GACrD,IAAA,GAAA,mBAAA,WAAA,CACW,mBAAmB,IAAA,GAAA,mBAAA,OAAA,CACvB,qBAAqBC,mBAAAA,WAAW,mDAAmD,IAAA,GAAA,mBAAA,OAAA,CACnF,mBAAmBA,mBAAAA,WAAW,iDAAiD,CACxF;8BAEE,eAAe,UAAU,IAAA,GAAA,mBAAA,WAAA,CACd,gBAAgB,IAAA,GAAA,mBAAA,OAAA,CACpB,kBAAkBC,mBAAAA,QAAQ,0BAA0B,CAC7D;+BAEG,OAAA,GAAA,mBAAA,SAAA,CAAwB,EAAE,QAAQ,KAAK,EAAA,GAAA,mBAAA,QAAA,CAAS,cAAc,CAAC,CAAC,EAAE,SAAS,IAAI,IAAA,GAAA,mBAAA,KAAA,CAC3E,iBAAiB,wBAAwB,CAChD;AACF,CAAC,CAAC,CAAC,OAAO;;;;;;AAOZ,MAAa,0BAA0B,SAAkB,QAAwC;CAC/F,MAAM,QAAS,IAAI,MAA6B,SAAS,CAAC;CAC1D,OAAO,gBAAgB,SAAS,IAAI,KAAA,GAAA,8BAAA,aAAA,CAAiB,KAAK,CAAC,CAAC;AAC9D;;;;;;;;ACxGA,MAAa,cAAmC;CAC9C,MAAM;CACN,aAAa;CACb,WAAW,SAAS,QAAQ,uBAAuB,SAAS,GAAG;AACjE"}
|