Last active
March 30, 2026 21:32
-
-
Save KoStard/d46c91a12660ff9760dbba8714117254 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Tissot-Style Golden Watch with Leather Strap | |
| // ForgeCAD model — Z-up, all dimensions in mm | |
| // === Parameters === | |
| const caseDia = param("Case Diameter", 40, { min: 34, max: 46, unit: "mm" }); | |
| const caseThick = param("Case Thickness", 10, { min: 7, max: 14, unit: "mm" }); | |
| const strapW = param("Strap Width", 20, { min: 16, max: 24, unit: "mm" }); | |
| const strapLen = param("Strap Length", 80, { min: 60, max: 110, unit: "mm" }); | |
| const caseR = caseDia / 2; | |
| const bezelR = caseR - 1; | |
| const dialR = bezelR - 1.5; | |
| const wallThick = 2; | |
| const dialDepth = 3; | |
| // === Colors === | |
| const GOLD = '#C5A855'; | |
| const GOLD_DARK = '#846d2f'; | |
| const LEATHER = '#5C3A1E'; | |
| const LEATHER_DARK = '#3A2210'; | |
| const DIAL_SILVER = '#C8CDD4'; | |
| const HAND_GOLD = '#D4AF37'; | |
| // ======================================== | |
| // WATCH CASE — hollowed, open on top so dial is visible | |
| // ======================================== | |
| // Full outer cylinder | |
| const caseOuter = cylinder(caseThick, caseR, caseR, 64, true); | |
| // Cavity carved from the top — leaves a floor (wallThick) at the bottom | |
| // and thin walls around the sides | |
| const cavityDepth = caseThick - wallThick; | |
| const cavity = cylinder(cavityDepth + 0.1, dialR + 0.5, dialR + 0.5, 64, true) | |
| .translate(0, 0, (caseThick - cavityDepth) / 2 + 0.05); | |
| const caseBody = difference(caseOuter, cavity) | |
| .color(GOLD) | |
| .material({ metalness: 0.85, roughness: 0.15 }); | |
| // Bezel ring on top edge | |
| const bezelRing = difference( | |
| cylinder(1.2, caseR, caseR, 64, true), | |
| cylinder(1.4, bezelR, bezelR, 64, true) | |
| ).translate(0, 0, caseThick / 2 - 0.4) | |
| .color(GOLD_DARK) | |
| .material({ metalness: 0.9, roughness: 0.1 }); | |
| // Case back | |
| const caseBack = cylinder(0.8, caseR - 0.5, caseR - 0.5, 64, true) | |
| .translate(0, 0, -caseThick / 2 - 0.3) | |
| .color(GOLD_DARK) | |
| .material({ metalness: 0.8, roughness: 0.2 }); | |
| // ======================================== | |
| // DIAL — sits on the cavity floor | |
| // ======================================== | |
| const dialZ = caseThick / 2 - dialDepth; | |
| const dial = cylinder(0.5, dialR, dialR, 64, true) | |
| .translate(0, 0, dialZ) | |
| .color(DIAL_SILVER) | |
| .material({ metalness: 0.4, roughness: 0.3 }); | |
| const minuteTrack = difference( | |
| cylinder(0.3, dialR, dialR, 64, true), | |
| cylinder(0.5, dialR - 1.2, dialR - 1.2, 64, true) | |
| ).translate(0, 0, dialZ + 0.4) | |
| .color(GOLD) | |
| .material({ metalness: 0.8, roughness: 0.15 }); | |
| // ======================================== | |
| // HOUR MARKERS | |
| // ======================================== | |
| const markers = []; | |
| for (let i = 0; i < 12; i++) { | |
| const angle = (i * 30) * Math.PI / 180; | |
| const markerR = dialR - 3.5; | |
| const markerLen = i % 3 === 0 ? 3.5 : 2.2; | |
| const markerW = i % 3 === 0 ? 1.2 : 0.8; | |
| const marker = box(markerW, markerLen, 0.6, true) | |
| .rotate(0, 0, -i * 30) | |
| .translate( | |
| Math.sin(angle) * markerR, | |
| Math.cos(angle) * markerR, | |
| dialZ + 0.6 | |
| ); | |
| markers.push({ | |
| name: `Marker ${i === 0 ? 12 : i}`, | |
| shape: marker, | |
| color: GOLD, | |
| }); | |
| } | |
| // ======================================== | |
| // WATCH HANDS — inside cavity, joint-controlled | |
| // ======================================== | |
| const handZ = dialZ + 0.8; | |
| const hourHand = box(1.6, 10, 0.8, true) | |
| .translate(0, 5, handZ); | |
| const minuteHand = box(1.1, 14.5, 0.6, true) | |
| .translate(0, 7.25, handZ + 0.2); | |
| const secondHand = box(0.4, 15.5, 0.3, true) | |
| .translate(0, 6, handZ + 0.4); | |
| const centerPin = cylinder(1.5, 1.2, 1.2, 24, true) | |
| .translate(0, 0, handZ + 0.6) | |
| .color(GOLD) | |
| .material({ metalness: 0.95, roughness: 0.05 }); | |
| // ======================================== | |
| // JOINTS — interactive hand rotation | |
| // ======================================== | |
| jointsView({ | |
| joints: [ | |
| { | |
| name: "Hours", | |
| child: "Hour Hand", | |
| type: "revolute", | |
| axis: [0, 0, -1], | |
| pivot: [0, 0, handZ], | |
| min: 0, max: 360, default: 60, | |
| unit: "°", | |
| }, | |
| { | |
| name: "Minutes", | |
| child: "Minute Hand", | |
| type: "revolute", | |
| axis: [0, 0, -1], | |
| pivot: [0, 0, handZ + 0.2], | |
| min: 0, max: 360, default: 150, | |
| unit: "°", | |
| }, | |
| { | |
| name: "Seconds", | |
| child: "Second Hand", | |
| type: "revolute", | |
| axis: [0, 0, -1], | |
| pivot: [0, 0, handZ + 0.4], | |
| min: 0, max: 360, default: 220, | |
| unit: "°", | |
| }, | |
| ], | |
| animations: [ | |
| { | |
| name: "Tick", | |
| duration: 60, | |
| loop: true, | |
| continuous: true, | |
| keyframes: [ | |
| { at: 0, values: { "Hours": 0, "Minutes": 0, "Seconds": 0 } }, | |
| { at: 1, values: { "Hours": 30, "Minutes": 360, "Seconds": 21600 } }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| // ======================================== | |
| // CROWN | |
| // ======================================== | |
| const crownBody = cylinder(4, 2.5, 2.5, 16, true) | |
| .rotate(0, 90, 0) | |
| .translate(caseR + 2.5, 0, 0) | |
| .color(GOLD) | |
| .material({ metalness: 0.85, roughness: 0.2 }); | |
| const crownRidges = []; | |
| for (let i = -1; i <= 1; i++) { | |
| const ridge = difference( | |
| cylinder(0.5, 2.8, 2.8, 16, true), | |
| cylinder(0.6, 2.3, 2.3, 16, true) | |
| ).rotate(0, 90, 0) | |
| .translate(caseR + 2.5 + i * 1.2, 0, 0); | |
| crownRidges.push({ | |
| name: `Crown Ridge ${i + 2}`, | |
| shape: ridge, | |
| color: GOLD_DARK, | |
| }); | |
| } | |
| const crownGuard = box(3, 4, 4, true) | |
| .translate(caseR + 0.5, 0, 0) | |
| .color(GOLD) | |
| .material({ metalness: 0.85, roughness: 0.15 }); | |
| // ======================================== | |
| // LUGS | |
| // ======================================== | |
| const lugs = []; | |
| const lugOffsetX = strapW / 2 - 2; | |
| const lugOffsetY = caseR - 2; | |
| for (let side = 0; side < 2; side++) { | |
| for (let lr = -1; lr <= 1; lr += 2) { | |
| const ySign = side === 0 ? 1 : -1; | |
| const lug = box(5, 8, caseThick * 0.7, true) | |
| .translate(lr * lugOffsetX, ySign * (lugOffsetY + 3), 0); | |
| lugs.push({ | |
| name: `Lug ${side === 0 ? 'Top' : 'Bot'} ${lr < 0 ? 'L' : 'R'}`, | |
| shape: lug, | |
| color: GOLD, | |
| }); | |
| } | |
| const bar = cylinder(7, 0.6, 0.6, 12, true) | |
| .rotate(0, 90, 0) | |
| .translate(0, (side === 0 ? 1 : -1) * (lugOffsetY + 5), 0); | |
| lugs.push({ | |
| name: `Bar ${side === 0 ? 'Top' : 'Bot'}`, | |
| shape: bar, | |
| color: GOLD_DARK, | |
| }); | |
| } | |
| // ======================================== | |
| // TOP STRAP — buckle, no holes | |
| // ======================================== | |
| const strapTop = box(strapW, strapLen, 2.5, true) | |
| .translate(0, caseR + strapLen / 2 + 3, -1) | |
| .color(LEATHER) | |
| .material({ roughness: 0.85, metalness: 0 }); | |
| const buckleY = caseR + strapLen + 5; | |
| const buckleFrame = difference( | |
| box(strapW + 4, 8, 2, true), | |
| box(strapW - 2, 5, 3, true) | |
| ).translate(0, buckleY, -0.5) | |
| .color(GOLD) | |
| .material({ metalness: 0.85, roughness: 0.15 }); | |
| const bucklePin = cylinder(strapW + 2, 0.5, 0.5, 12, true) | |
| .rotate(0, 90, 0) | |
| .translate(0, buckleY - 2, 0.5) | |
| .color(GOLD_DARK) | |
| .material({ metalness: 0.9, roughness: 0.1 }); | |
| const keeperTop = difference( | |
| box(strapW + 2, 4, 4, true), | |
| box(strapW, 3, 3, true) | |
| ).translate(0, caseR + strapLen * 0.4, -1) | |
| .color(LEATHER_DARK) | |
| .material({ roughness: 0.8, metalness: 0 }); | |
| // ======================================== | |
| // BOTTOM STRAP — holes + curved pointed tip | |
| // ======================================== | |
| const halfW = strapW / 2; | |
| const strapProfile = path() | |
| .moveTo(-halfW, 0) | |
| .lineTo(-halfW, -strapLen) | |
| .arcTo(-halfW + 3, -strapLen - 6, 8, false) | |
| .arcTo(0, -strapLen - 12, 12, false) | |
| .arcTo(halfW - 3, -strapLen - 6, 12, false) | |
| .arcTo(halfW, -strapLen, 8, false) | |
| .lineTo(halfW, 0) | |
| .close(); | |
| const strapBottomSolid = strapProfile.extrude(2.5) | |
| .translate(0, -(caseR + 3), -2.25); | |
| const strapHoles = []; | |
| for (let i = 0; i < 5; i++) { | |
| const hole = cylinder(3, 1.2, 1.2, 12, true) | |
| .translate(0, -(caseR + 20 + i * 10), -1); | |
| strapHoles.push(hole); | |
| } | |
| const strapBotWithHoles = difference(strapBottomSolid, ...strapHoles); | |
| // ======================================== | |
| // BRANDING | |
| // ======================================== | |
| const brandText = text2d('TISSOT', { size: 3, align: 'center', baseline: 'center' }) | |
| .extrude(0.3) | |
| .translate(0, 6, dialZ + 0.3) | |
| .color(GOLD_DARK); | |
| const swissMade = text2d('SWISS MADE', { size: 1.5, align: 'center', baseline: 'center' }) | |
| .extrude(0.2) | |
| .translate(0, -10, dialZ + 0.3) | |
| .color('#8890A0'); | |
| const yearText = text2d('1853', { size: 1.8, align: 'center', baseline: 'center' }) | |
| .extrude(0.2) | |
| .translate(0, 3, dialZ + 0.3) | |
| .color('#8890A0'); | |
| // ======================================== | |
| // DATE WINDOW — real calendar day | |
| // ======================================== | |
| const today = new Date(); | |
| const dayNum = today.getDate().toString(); | |
| const dateWindow = box(4.5, 3.5, 1, true) | |
| .translate(11, 0, dialZ + 0.3) | |
| .color('#FFFFFF'); | |
| const dateFrame = difference( | |
| box(5.5, 4.5, 0.6, true), | |
| box(4.5, 3.5, 1, true) | |
| ).translate(11, 0, dialZ + 0.6) | |
| .color(GOLD) | |
| .material({ metalness: 0.85, roughness: 0.15 }); | |
| const dateText = text2d(dayNum, { size: 2.2, align: 'center', baseline: 'center' }) | |
| .extrude(0.2) | |
| .translate(11, 0, dialZ + 0.5) | |
| .color('#1A1A1A'); | |
| // ======================================== | |
| // GLASS CRYSTAL — transparent lid | |
| // ======================================== | |
| const crystal = cylinder(0.8, bezelR - 0.3, bezelR - 0.3, 64, true) | |
| .translate(0, 0, caseThick / 2 - 0.2) | |
| .color('#E8EDF2') | |
| .material({ opacity: 0.052, metalness: 0.1, roughness: 0.05 }); | |
| // ======================================== | |
| // SCENE | |
| // ======================================== | |
| scene({ | |
| background: { top: '#1a1a2e', bottom: '#0a0a14' }, | |
| // camera: { | |
| // position: [55, -75, 90], | |
| // target: [0, 5, 0], | |
| // fov: 42, | |
| // }, | |
| environment: { | |
| preset: 'studio', | |
| intensity: 0.6, | |
| }, | |
| lights: [ | |
| // Soft ambient fill so nothing goes full black | |
| { type: 'ambient', color: '#c8cdd4', intensity: 0.15 }, | |
| // Key light — warm, upper-right, casts shadow | |
| { type: 'directional', position: [80, -60, 120], target: [0, 0, 0], color: '#fff4e0', intensity: 1.8, castShadow: true }, | |
| // Rim/back light — cool, for edge separation | |
| { type: 'directional', position: [-60, 40, 80], target: [0, 0, 0], color: '#b0c4de', intensity: 0.7 }, | |
| // Low fill — warm bounce from below to light the strap | |
| { type: 'point', position: [0, -30, -30], color: '#ffe8cc', intensity: 0.6, distance: 200, decay: 1.5 }, | |
| // Accent highlight on the crown side | |
| { type: 'point', position: [50, 0, 20], color: '#ffd700', intensity: 0.4, distance: 120, decay: 2 }, | |
| ], | |
| postProcessing: { | |
| bloom: { intensity: 0.3, threshold: 0.85, radius: 0.3 }, | |
| vignette: { darkness: 0.5, offset: 0.4 }, | |
| toneMappingExposure: 1.3, | |
| }, | |
| }); | |
| // ======================================== | |
| // RETURN ALL PARTS | |
| // ======================================== | |
| return [ | |
| { name: "Case Body", shape: caseBody }, | |
| { name: "Bezel Ring", shape: bezelRing }, | |
| { name: "Case Back", shape: caseBack }, | |
| { name: "Dial", shape: dial }, | |
| { name: "Minute Track", shape: minuteTrack }, | |
| ...markers, | |
| { name: "Hour Hand", shape: hourHand, color: HAND_GOLD }, | |
| { name: "Minute Hand", shape: minuteHand, color: HAND_GOLD }, | |
| { name: "Second Hand", shape: secondHand, color: GOLD_DARK }, | |
| { name: "Center Pin", shape: centerPin }, | |
| { name: "Crown", shape: crownBody }, | |
| ...crownRidges, | |
| { name: "Crown Guard", shape: crownGuard }, | |
| ...lugs, | |
| { name: "Strap Top", shape: strapTop }, | |
| { name: "Buckle Frame", shape: buckleFrame }, | |
| { name: "Buckle Pin", shape: bucklePin }, | |
| { name: "Keeper Top", shape: keeperTop }, | |
| { name: "Strap Bottom", shape: strapBotWithHoles, color: LEATHER }, | |
| { name: "Tissot Text", shape: brandText }, | |
| { name: "Swiss Made", shape: swissMade }, | |
| { name: "Year 1853", shape: yearText }, | |
| { name: "Date Window", shape: dateWindow }, | |
| { name: "Date Frame", shape: dateFrame }, | |
| { name: "Date Number", shape: dateText }, | |
| { name: "Crystal", shape: crystal }, | |
| ]; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment